Skip to content

Fix missing default constraint on explicit interface implementations with unconstrained generics#5363

Merged
thomhurst merged 3 commits intomainfrom
fix/default-constraint-unconstrained-generics
Apr 4, 2026
Merged

Fix missing default constraint on explicit interface implementations with unconstrained generics#5363
thomhurst merged 3 commits intomainfrom
fix/default-constraint-unconstrained-generics

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

  • Fixes CS0453/CS0539 compilation errors when TUnit.Mocks generates explicit interface implementations for methods with unconstrained generic type parameters using nullable annotations (T?)
  • Detects unconstrained type parameters with nullable usage and emits where T : default on explicit interface implementations (MockWrapperTypeBuilder, MockBridgeBuilder)
  • Extracts shared IsUnconstrained helper to avoid duplicating constraint checks between GetGenericConstraints and the new detection logic

Closes #5362

Test plan

  • Added snapshot test Interface_With_Unconstrained_Nullable_Generic verifying where T : default is emitted for both Task<T?> and T? return types
  • All 25 existing snapshot tests continue to pass (no regressions)

…s with unconstrained generics

When TUnit.Mocks generates explicit interface implementations for methods
with unconstrained generic type parameters using nullable annotations (T?),
the required `where T : default` constraint was missing, causing CS0453 and
CS0539 compilation errors.

Closes #5362
@codacy-production
Copy link
Copy Markdown

codacy-production bot commented Apr 4, 2026

Not up to standards ⛔

🔴 Issues 5 minor

Alerts:
⚠ 5 issues (≤ 0 issues of at least minor severity)

Results:
5 new issues

Category Results
CodeStyle 5 minor

View in Codacy

🟢 Metrics 16 complexity

Metric Results
Complexity 16

View in Codacy

TIP This summary will be updated as you push new changes. Give us feedback

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This PR fixes a genuine C# compiler error (CS0453/CS0539) that occurs when a source-generated explicit interface implementation has an unconstrained generic type parameter used with nullable annotations (T?). The fix is correct and the approach is reasonable. Below are some observations and suggestions.


What the PR Does Well

  • Correctly identifies the root cause: where T : default is required on explicit interface implementations when T? is used, because C# needs to know whether T? means nullable-value-type or nullable-reference-type.
  • The IsUnconstrained() helper is a clean private extraction that avoids repeating the same six-condition check in both GetGenericConstraints and the new IsUnconstrainedWithNullableUsage.
  • The recursive HasNullableTypeParameter correctly handles nested generics (Task<T?>) and arrays.
  • Equality and hash code in MockTypeParameterModel are updated consistently.
  • Snapshot test covers both the Task<T?> and T? cases.

Concerns and Suggestions

1. NeedsDefaultConstraint is context-sensitive and stored on a model meant to be context-free

MockTypeParameterModel is a serializable, equatable model snapshot of a type parameter. NeedsDefaultConstraint is not really a property of the type parameter itself — it is a derived consequence of how that type parameter is used in a specific method signature. Storing it on the model is workable but introduces a subtle conceptual mismatch.

A cleaner alternative would be to compute the where T : default clause lazily in FormatConstraintClauses by inspecting the method's full signature at the model level, or to encode the information as HasAnnotatedNullableUsage to be more explicit about what the flag actually means. The current name NeedsDefaultConstraint is already quite context-specific (it only "needs" it in explicit implementations), which is why the forExplicitImplementation flag in FormatConstraintClauses has to gate it.

Consider renaming to HasAnnotatedNullableUsage to better reflect what was detected vs. what action to take. The action of emitting where T : default is then a decision made by the code generator based on context.

2. IsUnconstrainedWithNullableUsage only checks the method symbol — properties are not covered

The NeedsDefaultConstraint flag is populated only from IMethodSymbol in MemberDiscovery.cs:

method.TypeParameters.Select(tp => new MockTypeParameterModel
{
    Name = tp.Name,
    Constraints = tp.GetGenericConstraints(),
    NeedsDefaultConstraint = tp.IsUnconstrainedWithNullableUsage(method)
})

C# interfaces can have generic indexers (though rare). More practically, the property discovery path at lines ~563 and ~628 in MemberDiscovery.cs creates MockTypeParameterModel entries but would call IsUnconstrainedWithNullableUsage with a method symbol that is a property accessor, not the original method. If this path does not go through IsUnconstrainedWithNullableUsage at all for properties, that's fine (properties can't have their own type parameters in C#), but it's worth confirming that the property discovery path never produces a non-empty TypeParameters array, so the new flag is unreachable from that code path and cannot produce incorrect output.

3. HasNullableTypeParameter does not recurse into tuple elements or pointer types

The recursive traversal handles INamedTypeSymbol (for generic type arguments) and IArrayTypeSymbol (for element types), but skips:

  • ITupleTypeSymbol — tuples with nullable type parameter elements: (T?, string)
  • IPointerTypeSymbol — unsafe pointer types

For IPointerTypeSymbol this is likely not a concern in practice (pointers can't be nullable reference types). For tuples, (T?, string) in an interface method would trigger the same CS0453 and is not currently detected. This is an edge case but worth documenting or adding a // TODO comment so future maintainers know the scope of coverage.

4. The forExplicitImplementation boolean flag could become an enum for clarity

Currently, GetConstraintClauses takes a bool forExplicitImplementation = false parameter, and the interpretation "emit where T : default when this is an explicit implementation" is implicit. As the generator gains more call sites or more nuanced constraint requirements, a boolean flag tends to accumulate more flags alongside it. An enum like ConstraintContext { RegularImpl, ExplicitImpl } would make the intent clear at each call site without adding boolean proliferation.

This is a minor readability point, not a blocking concern.

5. Early-exit refactoring in GetGenericConstraints slightly changes observable behavior (benign)

Before this PR, GetGenericConstraints would return "" for an unconstrained type parameter via the final constraints.Count > 0 ? ... : "" guard. After the PR, it returns early via if (typeParam.IsUnconstrained()) return "";. The end result is the same, but the refactor also silently dropped the constraints.Count > 0 check from the final return statement (changed to return string.Join(", ", constraints);). This is still correct because the early-exit handles the empty case, but it is worth noting as a subtle structural change.


Summary

The fix is correct and solves a real compiler error. The main thing to consider is whether NeedsDefaultConstraint on MockTypeParameterModel is the right abstraction boundary (renaming it to HasAnnotatedNullableUsage would make it clearer), and whether the tuple recursion gap in HasNullableTypeParameter is worth a comment. Neither concern is blocking for the stated goal.

The model property now describes what was detected (nullable annotation
on an unconstrained type parameter) rather than what action to take.
The decision to emit `where T : default` remains in the builder layer,
gated by the forExplicitImplementation flag.
@thomhurst
Copy link
Copy Markdown
Owner Author

Addressed review feedback:

Item 1 (Rename): Renamed NeedsDefaultConstraintHasAnnotatedNullableUsage across model, discovery, and builder. The model now describes what was detected; the builder decides whether to emit where T : default based on context.

Item 2 (Properties): No action needed — C# properties cannot have their own type parameters, so TypeParameters is always empty on property models.

Item 3 (Tuples): No action needed — Roslyn represents tuples as INamedTypeSymbol (with IsTupleType == true). There is no ITupleTypeSymbol interface. The existing INamedTypeSymbol branch already recurses into TypeArguments, so (T?, string) is handled.

Item 4 (Bool → enum): Skipping — single boolean with one call-site distinction doesn't warrant an enum yet.

Item 5 (Early-exit): Acknowledged as benign. The IsUnconstrained() early-exit handles the empty case, making the final constraints.Count > 0 ternary unnecessary.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review

The author has already incorporated the NeedsDefaultConstraintHasAnnotatedNullableUsage rename from the previous review round — thank you, that is a clear improvement. The remaining observations from the first pass are still applicable:


Still open: tuple elements not covered by HasNullableTypeParameter

HasNullableTypeParameter recurses into INamedTypeSymbol type arguments and IArrayTypeSymbol element types, but not into ITupleTypeSymbol elements. A method like:

(T?, string) GetPair<T>();

would not trigger the where T : default emission, which would still cause CS0453 when the explicit interface implementation is generated. This is a genuine gap. Even if tuples with unconstrained nullable generic elements are rare in practice, it is worth either:

  1. Adding a recursive branch for ITupleTypeSymbol in HasNullableTypeParameter, or
  2. Adding a // Note: tuple element types are not inspected comment so future maintainers understand the scope.

Minor: forExplicitImplementation boolean could become an enum over time

The bool forExplicitImplementation = false parameter on GetConstraintClauses / FormatConstraintClauses is fine for now, but if additional constraint contexts emerge (e.g., override forwarding methods that need special treatment), a named enum like ConstraintContext { RegularImpl, ExplicitImpl } would scale better. Not blocking, just something to keep in mind.


Confirmed correct behaviour

  • where T : default is correctly emitted on explicit interface implementations in both MockWrapperTypeBuilder and MockBridgeBuilder.
  • The recursive HasNullableTypeParameter correctly handles Task<T?> and other nested generic cases.
  • MockTypeParameterModel equality/hash-code are updated consistently.
  • The snapshot test covers the two primary cases (Task<T?> return and T? return).
  • The early-return refactor in GetGenericConstraints (dropping the trailing constraints.Count > 0 guard) is safe because the unconstrained early-exit handles that case.

The fix is correct and solves a real compiler error. The tuple gap is the only remaining functional concern worth addressing before merge.

Proves that (T?, string) tuple return types are already handled by the
existing INamedTypeSymbol branch — Roslyn represents tuples as
ValueTuple<T?, string> which is an INamedTypeSymbol with TypeArguments.
There is no ITupleTypeSymbol interface in Roslyn.
@thomhurst
Copy link
Copy Markdown
Owner Author

Re: tuple elements — ITupleTypeSymbol does not exist in the Roslyn API. C# tuples are represented as INamedTypeSymbol (specifically System.ValueTuple<...>), so the existing INamedTypeSymbol branch already recurses into their TypeArguments.

Added a (T?, string) GetPair<T>() case to the snapshot test to prove it. The generated output correctly emits where T : default:

(T?, string) global::IFoo.GetPair<T>() where T : default => Object.GetPair<T>();

No additional handling needed.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review (third pass)

This pass covers the three commits since the last review round.


Commit 075c64ed: Rename NeedsDefaultConstraintHasAnnotatedNullableUsage

This is the rename suggested in the first review. It is a clear improvement — the name now describes what was detected (an annotated nullable usage) rather than prescribing a downstream action. No concerns here.


Commit d8ceeae3: Add tuple case to test + confirm Roslyn behaviour

The prior review flagged a potential gap: (T?, string) return types might not trigger where T : default because HasNullableTypeParameter had no branch for ITupleTypeSymbol. The commit message explains (correctly) that this gap does not exist:

Roslyn represents tuples as ValueTuple<T?, string> which is an INamedTypeSymbol with TypeArguments. There is no ITupleTypeSymbol interface in Roslyn.

The snapshot confirms where T : default is emitted for (T?, string) GetPair<T>(). The concern raised in the earlier reviews was therefore a false alarm — the INamedTypeSymbol branch already covers tuple element types.

The snapshot also confirms all three explicit interface implementations receive where T : default:

global::System.Threading.Tasks.Task<T?> global::IFoo.DoSomethingAsync<T>() where T : default
T? global::IFoo.GetValue<T>() where T : default
(T?, string) global::IFoo.GetPair<T>() where T : default

Remaining (now minor) observations

1. IPointerTypeSymbol not recursed — still not a concern

Pointer types cannot be nullable reference types, so T? in a pointer context is not valid C#. The omission is correct by language rules, but a one-line comment would help future readers:

// Pointer types cannot carry nullable annotations on type parameters, so no branch needed.

2. forExplicitImplementation boolean — still a readability nit, not blocking

The bool forExplicitImplementation = false parameter is now used in two call sites (MockWrapperTypeBuilder and MockBridgeBuilder). If a third constraint context ever arises, consider an enum. Not blocking.


Overall assessment

All substantive concerns from the previous review rounds have been addressed:

  • HasAnnotatedNullableUsage is a better name than NeedsDefaultConstraint.
  • The tuple recursion gap was a false alarm; Roslyn uses INamedTypeSymbol for tuples, and the test now proves it.
  • The IsUnconstrained() helper deduplication is clean.
  • Equality and hash-code on MockTypeParameterModel are consistent.
  • The early-return refactor in GetGenericConstraints is safe.

This PR is correct, fixes a real compiler error (CS0453/CS0539), and has adequate test coverage. Approving.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All substantive concerns from previous reviews have been addressed. The tuple recursion gap was a false alarm (Roslyn represents tuples as ValueTuple INamedTypeSymbol), the rename to HasAnnotatedNullableUsage is a clear improvement, and the snapshot now proves correct where T : default emission for Task<T?>, T?, and (T?, string) return types. Approving.

intellitect-bot pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Apr 7, 2026
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.24.18 to
1.28.7.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.28.7

<!-- Release notes generated using configuration in .github/release.yml
at v1.28.7 -->

## What's Changed
### Other Changes
* fix: prevent StringBuilder race in console interceptor during parallel
tests by @​thomhurst in thomhurst/TUnit#5414
### Dependencies
* chore(deps): update tunit to 1.28.5 by @​thomhurst in
thomhurst/TUnit#5415


**Full Changelog**:
thomhurst/TUnit@v1.28.5...v1.28.7

## 1.28.5

<!-- Release notes generated using configuration in .github/release.yml
at v1.28.5 -->

## What's Changed
### Other Changes
* perf: eliminate redundant builds in CI pipeline by @​thomhurst in
thomhurst/TUnit#5405
* perf: eliminate store.ToArray() allocation on mock behavior execution
hot path by @​thomhurst in thomhurst/TUnit#5409
* fix: omit non-class/struct constraints on explicit interface mock
implementations by @​thomhurst in
thomhurst/TUnit#5413
### Dependencies
* chore(deps): update tunit to 1.28.0 by @​thomhurst in
thomhurst/TUnit#5406


**Full Changelog**:
thomhurst/TUnit@v1.28.0...v1.28.5

## 1.28.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.28.0 -->

## What's Changed
### Other Changes
* fix: resolve build warnings in solution by @​thomhurst in
thomhurst/TUnit#5386
* Perf: Optimize MockEngine hot paths (~30-42% faster) by @​thomhurst in
thomhurst/TUnit#5391
* Move Playwright install into pipeline module by @​thomhurst in
thomhurst/TUnit#5390
* perf: optimize solution build performance by @​thomhurst in
thomhurst/TUnit#5393
* perf: defer per-class JIT via lazy test registration + parallel
resolution by @​thomhurst in
thomhurst/TUnit#5395
* Perf: Generate typed HandleCall<T1,...> overloads to eliminate
argument boxing by @​thomhurst in
thomhurst/TUnit#5399
* perf: filter generated attributes to TUnit-related types only by
@​thomhurst in thomhurst/TUnit#5402
* fix: generate valid mock class names for generic interfaces with
non-built-in type args by @​thomhurst in
thomhurst/TUnit#5404
### Dependencies
* chore(deps): update tunit to 1.27.0 by @​thomhurst in
thomhurst/TUnit#5392
* chore(deps): update dependency path-to-regexp to v8 by @​thomhurst in
thomhurst/TUnit#5378


**Full Changelog**:
thomhurst/TUnit@v1.27.0...v1.28.0

## 1.27.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.27.0 -->

## What's Changed
### Other Changes
* Fix Dependabot security vulnerabilities in docs site by @​thomhurst in
thomhurst/TUnit#5372
* fix: use 0.0.0-scrubbed sentinel version in snapshot scrubber to avoid
false Dependabot alerts by @​thomhurst in
thomhurst/TUnit#5374
* Speed up Engine.Tests by removing ProcessorCount parallelism cap by
@​thomhurst in thomhurst/TUnit#5379
* ci: add concurrency groups to cancel redundant workflow runs by
@​thomhurst in thomhurst/TUnit#5373
* Add scope-aware initialization and disposal OpenTelemetry spans to
trace timeline and HTML report by @​Copilot in
thomhurst/TUnit#5339
* Add WithInnerExceptions() for fluent AggregateException assertion
chaining by @​thomhurst in thomhurst/TUnit#5380
* Drop net6.0 and net7.0 TFMs, keep net8.0+ and netstandard2.x by
@​thomhurst in thomhurst/TUnit#5387
* Remove all [Obsolete] members and migrate callers by @​thomhurst in
thomhurst/TUnit#5384
* Add AssertionResult.Failed overload that accepts an Exception by
@​thomhurst in thomhurst/TUnit#5388
### Dependencies
* chore(deps): update dependency mockolate to 2.3.0 by @​thomhurst in
thomhurst/TUnit#5370
* chore(deps): update tunit to 1.25.0 by @​thomhurst in
thomhurst/TUnit#5371
* chore(deps): update dependency minimatch to v9.0.9 by @​thomhurst in
thomhurst/TUnit#5375
* chore(deps): update dependency path-to-regexp to v0.2.5 by @​thomhurst
in thomhurst/TUnit#5376
* chore(deps): update dependency minimatch to v10 by @​thomhurst in
thomhurst/TUnit#5377
* chore(deps): update dependency picomatch to v4 by @​thomhurst in
thomhurst/TUnit#5382
* chore(deps): update dependency svgo to v4 by @​thomhurst in
thomhurst/TUnit#5383
* chore(deps): update dependency path-to-regexp to v1 [security] by
@​thomhurst in thomhurst/TUnit#5385


**Full Changelog**:
thomhurst/TUnit@v1.25.0...v1.27.0

## 1.25.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.25.0 -->

## What's Changed
### Other Changes
* Fix missing `default` constraint on explicit interface implementations
with unconstrained generics by @​thomhurst in
thomhurst/TUnit#5363
* feat(mocks): add ReturnsAsync typed factory overload with method
parameters by @​thomhurst in
thomhurst/TUnit#5367
* Fix Arg.IsNull<T> and Arg.IsNotNull<T> to support nullable value types
by @​thomhurst in thomhurst/TUnit#5366
* refactor(mocks): use file-scoped types for generated implementation
details by @​thomhurst in thomhurst/TUnit#5369
* Compress HTML report JSON data and minify CSS by @​thomhurst in
thomhurst/TUnit#5368
### Dependencies
* chore(deps): update tunit to 1.24.31 by @​thomhurst in
thomhurst/TUnit#5356
* chore(deps): update dependency mockolate to 2.2.0 by @​thomhurst in
thomhurst/TUnit#5357
* chore(deps): update dependency polyfill to 9.24.1 by @​thomhurst in
thomhurst/TUnit#5365
* chore(deps): update dependency polyfill to 9.24.1 by @​thomhurst in
thomhurst/TUnit#5364


**Full Changelog**:
thomhurst/TUnit@v1.24.31...v1.25.0

## 1.24.31

<!-- Release notes generated using configuration in .github/release.yml
at v1.24.31 -->

## What's Changed
### Other Changes
* Fix Aspire 13.2.0+ timeout caused by ProjectRebuilderResource being
awaited by @​Copilot in thomhurst/TUnit#5335
* chore(deps): update dependency polyfill to 9.24.0 by @​thomhurst in
thomhurst/TUnit#5349
* Fix nullable IParsable type recognition in source generator and
analyzer by @​Copilot in thomhurst/TUnit#5354
* fix: resolve race condition in HookExecutionOrderTests by @​thomhurst
in thomhurst/TUnit#5355
* Fix MaxExternalSpansPerTest cap bypass when Activity.Parent chain is
broken by @​Copilot in thomhurst/TUnit#5352
### Dependencies
* chore(deps): update tunit to 1.24.18 by @​thomhurst in
thomhurst/TUnit#5340
* chore(deps): update dependency stackexchange.redis to 2.12.14 by
@​thomhurst in thomhurst/TUnit#5343
* chore(deps): update verify to 31.15.0 by @​thomhurst in
thomhurst/TUnit#5346
* chore(deps): update dependency polyfill to 9.24.0 by @​thomhurst in
thomhurst/TUnit#5348


**Full Changelog**:
thomhurst/TUnit@v1.24.18...v1.24.31

Commits viewable in [compare
view](thomhurst/TUnit@v1.24.18...v1.28.7).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit&package-manager=nuget&previous-version=1.24.18&new-version=1.28.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: default constraint missing from explicit interface implementations with unconstrained generics

1 participant