Understanding C# CS0165: Why Pattern Matching Fails in .NET 10 vs .NET Standard 2.1
The Mystery of CS0165 with Pattern Matching
When migrating or cross-targeting projects between older framework targets like .NET Standard 2.1 (using C# 8.0) and modern frameworks like .NET 10 (using C# 12/13), you may encounter unexpected compiler errors. A classic example involves pattern matching and the compiler error CS0165: Use of unassigned local variable.
Consider the following expression:
public bool Satisfied => Cost is { Length: 0 } cost || Foo(cost);This code might build cleanly under older SDKs targeting .NET Standard 2.1, yet fail to compile under .NET 10. Why does this happen, and why does the compiled binary from .NET Standard 2.1 run without errors under the .NET 10 runtime?
Definite Assignment and Short-Circuit Evaluation
To understand why error CS0165 occurs, we need to analyze how C# evaluates logical OR (||) operators alongside Definite Assignment rules.
In the expression A || B:
- If
Aevaluates totrue, short-circuiting occurs andBis never evaluated. - If
Aevaluates tofalse, execution continues andBis evaluated.
Now look at Cost is { Length: 0 } cost:
- When the pattern matches (length is 0), the variable
costis assigned and the expression evaluates totrue. Short-circuiting preventsFoo(cost)from executing. - When the pattern does not match (length is not 0), the expression evaluates to
false, andFoo(cost)executes. However, because the pattern match failed,costwas never assigned a value.
Because Foo(cost) is only called when the pattern match fails, cost is guaranteed to be unassigned when passed to Foo(). Therefore, the compiler correctly raises error CS0165.
Why Did It Compile in .NET Standard 2.1?
If the logic is fundamentally flawed, why did it build in .NET Standard 2.1?
The answer comes down to a compiler bug fix in Roslyn (the C# compiler). When property patterns and pattern variables were introduced in C# 8.0, the compiler had a subtle bug in its definite assignment analysis for pattern matching within complex logical expressions. It incorrectly assumed that the variable introduced in the property pattern was definitely assigned across both branches of the logical OR operator.
In newer C# compiler releases, Roslyn patched this tracking logic. When building under .NET 10 (or any modern SDK), the updated compiler accurately flags unassigned variables in short-circuit conditions.
Why does the old binary still execute successfully?
C# compiler rules (such as definite assignment) are enforced strictly at compile time. At runtime, the JIT compiler and CLR operate on Intermediate Language (IL). As long as the IL produced by the older compiler is type-safe and valid, the runtime executes it without re-running C# language scoping checks. At runtime, memory is allocated for local variables default-initialized to zero or null, which is why Foo(cost) executed without throwing a runtime exception.
How to Fix CS0165 in Modern C#
Depending on your intent, here are clean, modern C# approaches to fix the code:
Option 1: Evaluate the Variable First
If you want to introduce a variable for Cost regardless of property conditions, assign it directly or use positional pattern matching:
public bool Satisfied
{
get
{
var cost = Cost;
return cost.Length == 0 || Foo(cost);
}
}Option 2: Simplify Without Redundant Pattern Variables
If Cost is a property access, you can directly pass Cost to the method or use modern property patterns without declaring a redundant local variable:
public bool Satisfied => Cost.Length == 0 || Foo(Cost);Summary
Error CS0165 in this context is a legitimate C# compiler protection against reading unassigned variables during short-circuit evaluation. If code compiled under older versions of C# or .NET Standard 2.1, it was due to a known Roslyn compiler bug during early C# 8 pattern-matching tracking that has since been corrected in modern SDKs.