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 A evaluates to true, short-circuiting occurs and B is never evaluated.
  • If A evaluates to false, execution continues and B is evaluated.

Now look at Cost is { Length: 0 } cost:

  • When the pattern matches (length is 0), the variable cost is assigned and the expression evaluates to true. Short-circuiting prevents Foo(cost) from executing.
  • When the pattern does not match (length is not 0), the expression evaluates to false, and Foo(cost) executes. However, because the pattern match failed, cost was 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.