Dynamic query failures often happen before SQL exists. A generated expression can call the right method name with the wrong CLR argument type, or it can compile successfully while EF Core cannot translate it for a relational provider. This guide explains EF Core expression type alignment, nullable Contains handling, translation boundaries, and a fixed-version verification workflow without relying on a live database.

Core rule: align with the selected method parameter

When generating a Contains call, compare the property expression with the actual parameter type of the selected method. Add a conversion only when those types differ and the intended null semantics make that conversion valid.

An expression tree is a typed object graph, not source text that automatically receives every conversion a C# compiler might insert. If the collection is List, its selected Contains method accepts an int. Passing an expression whose Type is int? directly to that method is invalid. Expression.Call validates the supplied argument against the method parameter and raises ArgumentException while the tree is being built. No provider, SQL generator, or database is involved at that point.

The reverse case matters just as much. A List has a Contains parameter of int?. Automatically unwrapping every nullable property because Nullable.GetUnderlyingType returns a type would damage a call that was already correct. The robust source of truth is method.GetParameters()[0].ParameterType, combined with the property expression's Type. Preserve an exact match. For a mismatch, validate that the conversion is supported, define null behavior, and only then create Expression.Convert.

Null behavior is a separate contract. Converting a null int? to int fails when the compiled expression runs. If a null property should not match a non-null collection, combine HasValue with the converted call by using AndAlso. If the collection can contain null and null is a meaningful match, select a method that accepts int? and retain the nullable property. A conversion cannot decide business semantics; it can only represent a type operation that the generator has already justified.

This parameter-first rule also improves diagnostics. A failure report can state the selected method, declaring collection type, property CLR type, expected argument type, and null policy. That information is safe and sufficient for debugging without logging row contents. It also makes code review concrete: reviewers can check method selection and type compatibility rather than infer behavior from a broad helper name.

Why a type error can look like an empty result

A data layer that catches expression-building or query-execution exceptions and returns an empty collection erases the distinction between “the query matched nothing” and “the query never completed.” The caller then diagnoses data instead of code.

The defect may appear only when data exists. Some query builders inspect the input sequence or condition list and return early when there is nothing to process. An empty fixture can therefore avoid constructing the faulty expression. A populated fixture reaches the builder, triggers the incompatible call, and fails. If an empty catch converts that failure into zero rows, the visible symptom changes according to fixture state even though the underlying generator is consistently wrong.

Preserve the failure boundary instead. During expression construction, capture the condition identifier, operator, property CLR type, collection element type, selected method signature, and exception type. Do not capture field values. During query execution, allow translation exceptions to reach a boundary that can classify them. A result model should distinguish successful zero rows from a failed query; at minimum, do not silently replace a failure with an ordinary collection.

A useful test matrix needs three data states: one matching row, populated data with no match, and no data. The matching state forces construction and proves positive behavior. The nonmatching populated state proves that zero is a legitimate completed result. The empty state checks early-return behavior. For nullable fields, add null, a value inside the collection, and a value outside it. These cases prevent a narrow fix from passing only the easiest branch.

Observability does not mean exposing database details. Stable event names and structured type metadata are enough. Count stages only after each stage completes, and reserve a distinct state for an exception. A single count of zero cannot communicate whether filtering ran. This practice turns a misleading application symptom into an actionable generator or translation error.

Build an auditable Contains expression

A safe builder first fixes the collection type and exact overload, then validates parameter count, argument type, conversion support, and null policy. Only after those decisions should it create the call node and lambda.

The following example represents one explicit policy: a nullable integer property is tested against a non-null integer collection, and null means no match. HasValue protects the conversion. A reusable implementation should reject unrelated type pairs rather than applying Expression.Convert to every mismatch.

var entity = Expression.Parameter(typeof(Row), "row");
var property = Expression.Property(entity, nameof(Row.Type)); // int?
var values = Expression.Constant(new List { 1, 2 });
var method = typeof(List).GetMethod("Contains", new[] { typeof(int) })!;
var expectedType = method.GetParameters()[0].ParameterType;

var hasValue = Expression.Property(property, nameof(Nullable.HasValue));
Expression argument = property.Type == expectedType
    ? property
    : Expression.Convert(property, expectedType);
var call = Expression.Call(values, method, argument);
var body = Expression.AndAlso(hasValue, call);
var predicate = Expression.Lambda>(body, entity);

Overload selection deserves its own guard. Reflection code that retrieves the first method named Contains is fragile because a type can expose multiple methods with different signatures. Filter candidates by method name, generic shape where relevant, parameter count, and expected element type. Require one result. If selection is ambiguous or absent, fail while building the specification instead of guessing and compensating with conversions.

The same alignment logic should power NotContains. Build and validate the positive call, including its null policy, and then negate the resulting Boolean expression with Expression.Not. Do not duplicate method discovery in a second branch. Duplication creates a path where one operator receives a type fix and its inverse keeps the old defect.

A more general generator should classify conversions. Exact identity is safest. Nullable-to-underlying conversion requires a null policy. Reference assignability may need no conversion, depending on the method parameter. Numeric widening and enum conversions require explicit product rules because a technically constructible conversion may change query meaning or provider translation. Unsupported pairs should produce a descriptive construction error. “Try conversion and see” is not a maintainable type system.

Finally, test the generated expression as both a compiled delegate and an EF Core query. The delegate verifies CLR behavior such as null guarding and membership. Provider execution verifies translation. Passing one layer says nothing definitive about the other: an expression can compile but fail translation, while a provider-specific expression may not behave like an ordinary in-memory method.

String Contains and the EF Core translation boundary

Do not assume that the string.Contains overload with StringComparison is translatable by a relational provider. When a non-translatable expression appears in a filter, EF Core throws rather than silently evaluating the entire filter in memory.

Microsoft's client-versus-server evaluation documentation defines the important boundary. EF Core permits limited client evaluation in the top-level projection. If an expression that cannot be translated appears elsewhere in the query, such as a Where predicate, EF Core raises a runtime exception. This protects applications from unexpectedly retrieving a large set and filtering it in the process. A dynamic query builder must therefore execute representative queries through the intended provider; compiling a lambda is not enough.

For database pattern matching, EF.Functions.Like expresses SQL-style matching. It is not a universal replacement for OrdinalIgnoreCase. Case sensitivity, accent handling, character equivalence, and wildcard behavior depend on the provider and collation. A correct design documents those semantics and tests them against the intended provider. It does not promise identical results across every database based on a single SQLite test.

Avoid inserting AsEnumerable before the filter merely to make an unsupported method run. That statement moves the remaining pipeline to client-side LINQ. The application may transfer and allocate far more data than the visible predicate suggests. An explicit client boundary can be reasonable for a proven-small collection, but it must be deliberate, reviewed, and measured in its actual context. It should never be a silent fallback in a generic query generator.

Query translation tests should include a positive match, a negative match, characters affected by the chosen collation, and pattern metacharacters when user input is converted into a Like pattern. If literal user text must remain literal, define and test an escaping strategy supported by the provider. This article's sandbox check uses a fixed literal pattern only; it does not claim to validate a complete search-input policy.

The official Expression.Call API documentation and EF Core query evaluation documentation support different layers of the conclusion. The former explains typed argument validation during tree construction. The latter explains provider translation behavior during execution. Treating these as separate phases helps engineers avoid diagnosing a construction exception as a database issue or a translation exception as a CLR type issue.

Verification and reproduction with .NET 9 and EF Core 9.0.9

The scheduler environment executed a non-destructive Windows sandbox test with .NET SDK 9.0.305 and Microsoft.EntityFrameworkCore.Sqlite 9.0.9. All four assertions passed against an in-memory SQLite connection.

  1. Create a net9.0 Console project, pin SDK 9.0.305 in global.json, reference Microsoft.EntityFrameworkCore.Sqlite version 9.0.9, and configure Data Source=:memory:.
  2. Select List.Contains(int) and pass an int? property expression directly. Pass when expression construction raises ArgumentException; fail if the call node is created.
  3. Align the property to the method parameter with Expression.Convert, compile the predicate, and evaluate values inside and outside the list. Pass only when the observations are true and false in that order.
  4. Execute a SQLite Where query using string.Contains with StringComparison.OrdinalIgnoreCase. Pass when EF Core raises InvalidOperationException, indicating that the filter was not translated.
  5. Replace that predicate with a fixed EF.Functions.Like expression against one sandbox row. Pass when the observable count is exactly one, then dispose the in-memory connection.
dotnet --version
dotnet restore ExpressionVerification.csproj
dotnet run --project ExpressionVerification.csproj

The real run reported MISMATCH_PASS: ArgumentException, CONVERT_PASS: true,false, TRANSLATION_PASS: InvalidOperationException, and LIKE_PASS: count=1. These lines are correctness assertions from the stated versions, not benchmark results. They do not establish timing, throughput, allocation rates, or behavior for another provider.

To reproduce the check, use a clean local test directory or disposable container with the pinned SDK and package. Do not point the sample at an existing database. If SDK 9.0.305 is unavailable, install that SDK or intentionally update global.json and record the new version as a separate verification. Do not compare mixed major versions as if they were the same test.

For another relational provider, keep the expression-construction and compiled-delegate checks unchanged, then add a provider integration project with temporary storage. Assert both the translated query result and the provider's documented collation behavior. A SQLite pass validates the specified SQLite combination only. Provider independence is a hypothesis that requires additional tests, not an inference from one green run.

The run also demonstrates why assertions should name the phase. MISMATCH_PASS belongs to expression construction. CONVERT_PASS belongs to CLR delegate semantics. TRANSLATION_PASS belongs to EF Core provider translation. LIKE_PASS belongs to the provider query result. A generic “test passed” would hide which contract was exercised and would make a future regression harder to localize.

Regression matrix and review checklist

A durable regression suite covers CLR types, collection element types, null truth tables, provider translation, and data states. A single non-null matching value is evidence for only one narrow path.

Start with a type matrix: int property versus List, int? versus List, and int? versus List. Add at least one unrelated pair that must be rejected. For every row, state whether the builder should use the property directly, convert with a guard, preserve nullable identity, or fail construction. Test both Contains and its negation from the same validated base expression.

Write the null truth table before implementing the helper. For a non-null collection, decide whether null is false for Contains and true for NotContains, or excluded from both operations. For a nullable collection, decide whether collection membership of null is supported. Each policy is possible, but accidental behavior from an unguarded conversion is not a policy. The tests should make a future semantic change visible.

Separate fast unit tests from provider tests. Unit tests inspect expression node types, selected MethodInfo, argument types, and compiled delegate outcomes. Provider tests create temporary schema and data, execute the IQueryable, and assert results or expected translation failures. Keep provider versions pinned. This separation gives quick feedback while retaining evidence that the expression works beyond in-memory LINQ.

During review, ask these questions in order: Was one exact overload selected? Are the property and parameter CLR types visible? Is every conversion classified and justified? Is null behavior asserted? Can query failure still be distinguished from zero rows? Did any AsEnumerable call move the boundary? Are string comparison semantics tied to a provider and collation? Can the fixed-version commands run in a clean sandbox? An unanswered item is a reason to hold the query-builder change.

Do not add broad exception handling around the new tests. Tests should fail with the original exception and phase information. Application boundaries may translate exceptions into stable error results, but they should retain structured diagnostic context. Returning an empty list is not graceful handling when the contract promises that a query was executed.

Documentation should accompany the helper. Record supported operators, type pairs, null semantics, provider assumptions, and explicit non-goals. Include one example for a nullable property with a non-null list and one for a nullable list. This small contract prevents future callers from assuming that a generic-sounding builder supports every conversion or string comparison.

Conclusion

Reliable EF Core dynamic queries depend on explicit contracts at two boundaries: CLR method-call construction and provider translation. Align arguments with the selected method, define null semantics separately, and execute provider tests instead of trusting compilation alone.

The immediate code change may be one Expression.Convert, but the maintainable solution includes exact overload selection, compatibility checks, a null guard, observable failures, and fixed-version regression tests. With those controls, a model or provider change causes a precise test failure rather than an unexplained empty result. That is the practical goal of EF Core expression type alignment: make query meaning explicit before data access begins and keep every failure attributable to the phase that produced it.