feat: covariant assertions for interfaces and non-sealed classes#5226
feat: covariant assertions for interfaces and non-sealed classes#5226
Conversation
When the target type of a [GenerateAssertion] or [AssertionExtension] is an
interface or non-sealed class, the generated extension method now uses a
generic type parameter with a constraint instead of the concrete type:
// Before:
public static Assertion HasFoo(this IAssertionSource<IInterface> source)
// After:
public static Assertion HasFoo<TActual>(this IAssertionSource<TActual> source)
where TActual : IInterface
This allows assertions defined for a base type to work seamlessly on derived
types without requiring explicit casts.
Covariance is applied only when safe:
- Interfaces and non-sealed classes: yes
- Value types, sealed classes, arrays, type parameters: no
- Types containing unresolved type parameters (e.g., Lazy<T>): no
Closes #4830
There was a problem hiding this comment.
Code Review: Covariant Assertions for Interfaces and Non-Sealed Classes
This is a well-designed feature that solves a real usability pain point. The core approach — introducing a generic TActual type parameter with a constraint — is the right solution for enabling covariant assertion chaining without explicit casts. The PR description is thorough, and the exclusion logic (sealed classes, value types, arrays, open generics) is sound.
Issue 1: AssertionExtensionGenerator is missing the ContainsTypeParameter guard
MethodAssertionGenerator explicitly excludes types that contain unresolved type parameters:
var isCovariantCandidate = (targetType.TypeKind == TypeKind.Interface || targetType.TypeKind == TypeKind.Class)
&& !targetType.IsSealed
&& !ContainsTypeParameter(targetType); // ← guards against Lazy<T>, IList<T>, etc.AssertionExtensionGenerator does not have this check:
var isCovariantCandidate = !isNullableOverload
&& (typeParam.TypeKind == TypeKind.Interface || typeParam.TypeKind == TypeKind.Class)
&& !typeParam.IsSealed; // ← no ContainsTypeParameter guardThe ITypeParameterSymbol check handles direct type parameters (e.g. T), but a named type like IList<T> or Lazy<T> would still pass as a covariant candidate if it appears as the assertion target type in an [AssertionExtension]-decorated class. This is an inconsistency that could produce broken generated code.
Suggestion: Add the same ContainsTypeParameter helper to AssertionExtensionGenerator, or extract it to a shared static utility, and include it in the isCovariantCandidate check.
Issue 2: Potential TActual name collision with user-defined generic parameters
When generating the combined generic declaration:
var allGenericParams = new List<string>();
if (isCovariant)
{
allGenericParams.Add("TActual");
}
allGenericParams.AddRange(genericParams); // ← user method's own type paramsIf a user writes an assertion method that already has a generic type parameter named TActual:
[GenerateAssertion(ExpectationMessage = "...")]
public static bool HasItem<TActual>(this IMyCollection target, TActual expected) => ...;The generated code would produce HasItem<TActual, TActual>(...) — a compilation error.
Suggestion: Either document that TActual is a reserved name for the generator, or pick a name that is unlikely to conflict (e.g. TActualSource__), or add a diagnostic error when the conflict is detected.
Minor: Use modern C# range syntax instead of Substring
CLAUDE.md specifies LangVersion is preview and to use the latest syntax. Both generators contain:
constraintType = typeParamDisplay.Substring(0, typeParamDisplay.Length - 1);Prefer:
constraintType = typeParamDisplay[..^1];What's done well
- The
ContainsTypeParameterrecursive helper inMethodAssertionGeneratorcorrectly handles arrays, nested generics, and direct type parameters. - Using
source.Context.Map<T>(static x => (T?)x)for the context narrowing is clean — thestaticlambda avoids closures, and explicitly specifying theTNewtype argument preserves nullability correctly. - The `[UnconditionalSuppressMessage("Trimming", "IL2091")]" attribute is correctly applied wherever a new generic parameter is introduced, maintaining AOT compatibility.
- Snapshot tests are properly updated.
- Correct exclusion of nullable overloads from the covariant path avoids duplicate-overload errors.
The two issues above (missing ContainsTypeParameter in AssertionExtensionGenerator and the TActual name collision) are worth addressing before merge, particularly the first one since it's a consistency gap between the two generators.
…rameter check - Extract duplicated covariance logic into CovarianceHelper static class - Fix bug: AssertionExtensionGenerator was missing ContainsTypeParameter check, which would incorrectly treat types like Lazy<T> as covariant - Remove dead code: unused isMultiParameterGeneric and isNullableReferenceType - Eliminate unnecessary List<string> allocation in non-covariant path - Remove redundant typeParamDisplay computation in AssertionExtensionGenerator
There was a problem hiding this comment.
Follow-up Review: Refactor Commit (4da27a1)
Reviewing the second commit against the previous review issues.
Issue 1 (from previous review): FIXED ✓
The missing ContainsTypeParameter guard in AssertionExtensionGenerator has been addressed by extracting the shared CovarianceHelper static class. Both generators now go through the same IsCovariantCandidate logic consistently. The extraction is clean — well-documented methods, focused responsibilities, and the static modifier is appropriate since there's no state.
Issue 2 (from previous review): Still open
The TActual name collision with user-defined generic parameters is still present. In MethodAssertionGenerator.cs:
var genericDeclaration = isCovariant
? genericParams.Count > 0
? $"<TActual, {string.Join(", ", genericParams)}>"
: "<TActual>"
: ...If a user writes:
[GenerateAssertion(ExpectationMessage = "...")]
public static bool Contains<TActual>(this IMyCollection target, TActual expected) => ...;The generated output becomes Contains<TActual, TActual>(...) — a compile error. The same applies to AssertionExtensionGenerator where genericTypeParam = "TActual" is hardcoded.
Suggested fix: Check for collision before emitting, or use a mangled name that's clearly reserved (e.g. TActualSource):
// In CovarianceHelper or at the call site:
private static string ChooseTActualName(IEnumerable<string> existingTypeParams)
{
const string preferred = "TActual";
var name = preferred;
int i = 0;
while (existingTypeParams.Contains(name))
name = $"{preferred}{++i}";
return name;
}Alternatively, document TActual as a reserved name (similar to how some generators reserve __-prefixed identifiers) and emit a Diagnostic error if the conflict is detected. The diagnostic approach would be more discoverable.
New observation: Substring vs range syntax in CovarianceHelper
CovarianceHelper.GetConstraintTypeName uses:
return typeName.Substring(0, typeName.Length - 1);CLAUDE.md specifies LangVersion = preview. Prefer:
return typeName[..^1];Minor point, but consistent with the codebase's style guidelines.
CovarianceHelper quality
The extracted helper is well-structured:
ContainsTypeParameteris correctly recursive over named type arguments, array element types, and direct type parameter symbols- The XML doc comments are clear and accurate
staticlambda inGetCovariantContextExpravoids closure allocation- Nullable annotations are properly propagated through
GetNullableCastType
The main outstanding item before merge remains the TActual name collision potential.
Use CovarianceHelper.GetCovariantTypeParamName() to pick a type parameter name that doesn't conflict with the method's existing generic parameters. Falls back to TActual_ (with appended underscores) if TActual is taken.
|
Thanks for the thorough review! Issue 1 (missing Issue 2 ( Minor (range syntax): The source generator targets |
There was a problem hiding this comment.
Final Review: All Previous Issues Addressed
Issue 1 (ContainsTypeParameter in AssertionExtensionGenerator): RESOLVED ✓
Both generators now route through CovarianceHelper.IsCovariantCandidate, which includes the ContainsTypeParameter guard. The extraction to a shared static class was the right architectural call — single responsibility, no duplication.
Issue 2 (TActual name collision): RESOLVED ✓
CovarianceHelper.GetCovariantTypeParamName correctly handles collisions by appending underscores:
var candidate = PreferredName + "_";
while (existing.Contains(candidate))
candidate += "_";This is functionally correct. A user would have to name their parameter TActual or TActual_ to encounter the fallback, which is extremely unlikely in practice.
Minor (Substring vs range syntax): Correctly unchanged
The source generator targets netstandard2.0, where System.Index/System.Range are unavailable. The Substring call is the correct approach here — good catch on the explanation.
Summary
The implementation is solid:
- Correct exclusion criteria (sealed, value types, arrays, open generics) via
IsCovariantCandidate - Clean
Map<T>(static x => (T?)x)pattern for context narrowing —staticlambda avoids allocations [UnconditionalSuppressMessage("Trimming", "IL2091")]applied consistently to all covariant methods, with an accurate justification- Snapshot tests updated correctly
CovarianceHelperis well-documented and focused
This is ready to merge. The feature delivers a genuine ergonomic improvement for users working with interface-typed assertions, and the implementation is conservative about when covariance applies.
There was a problem hiding this comment.
Follow-up Review: Commit aeb032d (chore: update public API snapshots)
This 4th commit is purely a public API snapshot update — no logic changes. All 4 modified files follow the same pattern: extending the verified API signatures to include the new <TActual> generic parameter and where TActual : <ConstraintType> constraint, consistent with the covariant generation changes from the earlier commits.
Snapshot Quality Check
The new signatures correctly match what the source generator now emits:
[UnconditionalSuppressMessage("Trimming", "IL2091", ...)]attribute is present on each covariant method ✓- Generic parameter
<TActual>appears in method signature ✓ where TActual : <BaseType>constraint is correctly specified ✓- Non-covariant types (sealed classes, value types) retain the old non-generic signature ✓
All three previous issues (missing ContainsTypeParameter guard, TActual name collision, Substring vs range syntax) were addressed in commits 2 and 3. This final commit is consistent with those fixes.
Summary
All previous review feedback has been addressed. The PR is in good shape and ready to merge. The public API snapshots correctly reflect the breaking change in method signatures (this is a source-compatible change for users — existing call sites continue to work due to type inference, while derived types no longer require explicit casts).
- Cache typeParam.ToDisplayString() to avoid duplicate call in AssertionExtensionGenerator covariant path - Replace HashSet with linear Contains() in GetCovariantTypeParamName (typical input has 0-2 items) - Inline single-use GetNullableCastType into GetCovariantContextExpr
There was a problem hiding this comment.
Follow-up Review: Commit 2faa66e (refactor: minor cleanup in covariance helpers)
Reviewing the 5th and latest commit against the previous review state (which concluded the PR was ready to merge after commit aeb032dfd9).
Changes in this commit
1. Cache typeParam.ToDisplayString() in AssertionExtensionGenerator
Previously the method was called up to 3 times across different code paths. Caching it as typeParamDisplay at the top of the method before the if/else if/else block is correct and clean — the value is the same regardless of which branch is taken.
2. Replace HashSet<string> with linear Contains() in GetCovariantTypeParamName
The reasoning in the commit message is correct — for 0–2 items, HashSet<T> allocation overhead exceeds the cost of linear search. One thing worth noting: the original HashSet approach materialized the enumerable once, while the new code can iterate it multiple times (once for the initial check, and once per loop iteration). In practice this is safe because the call sites pass a List<string> (which supports multiple enumeration), but if the signature ever changes to accept a lazily-evaluated IEnumerable<string>, the while-loop would re-evaluate on each iteration. This is a very low risk given the usage pattern, but worth a brief code comment if you want to guard against future misuse.
3. Inline GetNullableCastType into GetCovariantContextExpr
Removing a single-use private helper and inlining its one-liner body (typeName.EndsWith("?") ? typeName : $"{typeName}?") is the right call — it reduces indirection without losing clarity. The moved comment ("Map's Func takes TValue? and returns TNew?") is preserved on the calling method.
Summary
All three changes are correct and appropriately scoped. No regressions introduced. The PR remains in good shape — all issues from prior reviews have been resolved across the 5-commit history. Ready to merge.
There was a problem hiding this comment.
Follow-up Review: Commit 2faa66e (refactor: minor cleanup in covariance helpers)
Reviewing the 5th and latest commit against the previous review state (which concluded the PR was ready to merge after commit aeb032dfd9).
Changes in this commit
1. Cache typeParam.ToDisplayString() in AssertionExtensionGenerator
Previously called up to 3 times across different code paths. Caching it as typeParamDisplay at the top of the method before the if/else chain is correct and clean — the value is the same regardless of which branch is taken.
2. Replace HashSet<string> with linear Contains() in GetCovariantTypeParamName
The reasoning in the commit message is correct — for 0–2 items, HashSet allocation overhead exceeds the cost of linear search. One thing worth noting: the original HashSet approach materialized the enumerable once, while the new code can iterate existingGenericParams multiple times (once for the initial check, once per loop iteration in the collision-avoidance path). This is safe in all current call sites since the callers pass a List<string>, but if the signature ever changes to accept a lazily-evaluated IEnumerable<string>, the while-loop would re-evaluate on each iteration. Low risk given usage patterns, but a brief code comment would guard against future misuse.
3. Inline GetNullableCastType into GetCovariantContextExpr
Removing a single-use private helper and inlining its one-liner body is the right call — it reduces indirection without losing clarity. The explanatory comment ("Map's Func takes TValue? and returns TNew?") is preserved on the calling method.
Summary
All three changes are correct and appropriately scoped. No regressions introduced. The PR remains in good shape — all issues from prior reviews have been resolved across the 5-commit history. Ready to merge.
…r.Tests Add explicit ProjectReference with SetTargetFramework to guarantee the netstandard2.0 TFM of TUnit.Mocks is built before the test project tries to copy the DLL, fixing CI build failures on both ubuntu and windows.
There was a problem hiding this comment.
Follow-up Review: Commit 3c2cd47 (fix: ensure netstandard2.0 TUnit.Mocks built before SourceGenerator.Tests)
This is a single-file change to TUnit.Mocks.SourceGenerator.Tests/TUnit.Mocks.SourceGenerator.Tests.csproj, adding a build-order dependency.
What the change does
A second ProjectReference to the same TUnit.Mocks.csproj is added with:
SetTargetFramework="TargetFramework=netstandard2.0"— forces the netstandard2.0 TFM to be built before this test project startsReferenceOutputAssembly="false"— prevents it from being added as a code reference (avoiding conflict with the existing unrestricted reference on line 7)Private="false"— prevents the DLL from being copied to the output directory
This is the standard MSBuild idiom for expressing a TFM-specific build-order dependency without creating a duplicate assembly reference, and it's correct here.
Observation: Duplicate ProjectReference is intentional but slightly unusual
The csproj now references TUnit.Mocks.csproj twice:
<!-- Line 7 - assembly reference (builds the test project's TFM) -->
<ProjectReference Include="..\TUnit.Mocks\TUnit.Mocks.csproj" />
<!-- Lines 19-22 - build-order dependency only (forces netstandard2.0 build) -->
<ProjectReference Include="..\TUnit.Mocks\TUnit.Mocks.csproj"
SetTargetFramework="TargetFramework=netstandard2.0"
ReferenceOutputAssembly="false"
Private="false" />This is valid MSBuild behavior — the two references have different effective targets and the ReferenceOutputAssembly="false" prevents collisions. The comment on the new ItemGroup explains the intent clearly. No issues here.
Summary
The fix is correct and uses the right MSBuild pattern. All previous review issues remain resolved. The PR is ready to merge.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.21.6 to 1.21.20. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.21.20 <!-- Release notes generated using configuration in .github/release.yml at v1.21.20 --> ## What's Changed ### Other Changes * fix: respect TUnitImplicitUsings set in Directory.Build.props by @thomhurst in thomhurst/TUnit#5225 * feat: covariant assertions for interfaces and non-sealed classes by @thomhurst in thomhurst/TUnit#5226 * feat: support string-to-parseable type conversions in [Arguments] by @thomhurst in thomhurst/TUnit#5227 * feat: add string length range assertions by @thomhurst in thomhurst/TUnit#4935 * Fix BeforeEvery/AfterEvery hooks for Class and Assembly not being executed by @Copilot in thomhurst/TUnit#5239 ### Dependencies * chore(deps): update tunit to 1.21.6 by @thomhurst in thomhurst/TUnit#5228 * chore(deps): update dependency gitversion.msbuild to 6.7.0 by @thomhurst in thomhurst/TUnit#5229 * chore(deps): update dependency gitversion.tool to v6.7.0 by @thomhurst in thomhurst/TUnit#5230 * chore(deps): update aspire to 13.2.0 - autoclosed by @thomhurst in thomhurst/TUnit#5232 * chore(deps): update dependency typescript to v6 by @thomhurst in thomhurst/TUnit#5233 * chore(deps): update dependency polyfill to 9.23.0 by @thomhurst in thomhurst/TUnit#5235 * chore(deps): update dependency polyfill to 9.23.0 by @thomhurst in thomhurst/TUnit#5236 **Full Changelog**: thomhurst/TUnit@v1.21.6...v1.21.20 Commits viewable in [compare view](thomhurst/TUnit@v1.21.6...v1.21.20). </details> [](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>
Summary
[GenerateAssertion](MethodAssertionGenerator) and[AssertionExtension](AssertionExtensionGenerator) support covarianceLazy<T>)Example
Generated code change
Test plan
Closes #4830