Skip to content

Fix #3909: emit nullable override disambiguators - #3910

Merged
siegfriedpammer merged 5 commits into
icsharpcode:masterfrom
sailro:fix-3909-default-constraint
Jul 27, 2026
Merged

Fix #3909: emit nullable override disambiguators#3910
siegfriedpammer merged 5 commits into
icsharpcode:masterfrom
sailro:fix-3909-default-constraint

Conversation

@sailro

@sailro sailro commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Fixes #3909.

Problem

C# normally inherits generic constraints on overrides and explicit interface implementations, so
ILSpy correctly avoids restating them. Nullable annotations introduce one exception: when a
method type parameter appears as T?, C# requires class, struct, or default to distinguish
a nullable annotation from Nullable<T>.

Omitting that discriminator can change the signature and produce CS0115 / CS0453.

Solution

For overrides and explicit interface implementations:

  • collect method type parameters that appear with a nullable annotation anywhere in the return or
    parameter types;
  • skip value-type-constrained parameters, where T? already means Nullable<T>;
  • emit class for an inherited reference-type constraint, otherwise emit default.

Roslyn records the inherited reference/value constraint flags on the implementation method's own
type parameters, so this remains exact even when the base assembly cannot be resolved. Other
inherited constraints remain omitted, as required by C#.

Tests

Added a Roslyn 3+ Pretty fixture covering:

  • unconstrained and notnull parameters requiring default;
  • class and class? contracts requiring plain class;
  • struct and non-nullable-signature controls;
  • return-only and nested-array nullable annotations;
  • an explicit interface implementation.

Validation:

  • Issue3909 Pretty matrix: 6 passed

  • related generic / nullable Pretty matrices: 26 passed

  • full Pretty suite: 1,919 passed, 5 existing skips

  • type-system unit tests: 174 passed

  • Release solution build: 0 warnings, 0 errors

  • full solution test suite: 4,927 passed, 15 existing skips

  • At least one test covering the code changed

Override constraints are normally inherited and omitted, but nullable type
parameters still require class or default to distinguish annotations from
Nullable<T>. Derive that legal discriminator from the method metadata.

Assisted-by: Copilot:gpt-5.6-sol:GitHub Copilot CLI
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 86d2918e-5a24-48b4-9a86-41d331ec3720
@siegfriedpammer

Copy link
Copy Markdown
Member

Which version of c# was this feature introduced? Is there a specification?

@sailro

sailro commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

C# 9.0. There is a specification, though the situation is worth spelling out, because the standard has not caught up with it.

Version

The compiler is explicit about it. Compiling the fixture with LangVersion 8.0:

error CS8400: Feature 'default type parameter constraints' is not available in C# 8.0. Please use language version 9.0 or greater.
error CS8627: A nullable type parameter must be known to be a value type or non-nullable reference type unless language version '9.0' or greater is used.

The second one matters here: T? on a type parameter that is not constrained to class or struct is itself a C# 9 feature, so the annotation and the constraint always appear together. C# 8 already allowed restating class / struct on an override for this purpose; C# 9 only added default.

Specification

The normative text is the csharplang speclet Unconstrained type parameter annotations (champion issue dotnet/csharplang#3297, design meetings LDM 2019-11-25 and LDM 2020-06-17). Its "default constraint" section gives the two rules, both enforced by Roslyn:

  • it is an error to use default other than on a method override or explicit interface implementation - CS8823
  • it is an error to use default when the corresponding type parameter of the overridden or interface method is constrained to a reference type or a value type - CS8822 (with CS8665 as the converse guard for class: "... is not a reference type")

ECMA-334 does not cover the feature. The published 7th edition (December 2023) standardizes C# 7, and even the committee's C# 9 draft (dotnet/csharpstandard, branch draft-v9) has not incorporated it yet: in standard/classes.md, the 15.2.5 grammar is still

primary_constraint
    : class_type nullable_type_annotation?
    | 'class' nullable_type_annotation?
    | 'struct'
    | 'notnull'
    | 'unmanaged'
    ;

and 15.6.1 still reads "Such declarations may only have type_parameter_constraints_clauses containing the primary_constraints class and struct". So for now the speclet and the Roslyn implementation are the only authorities. The user documentation is where (generic type constraint).

A defect in this PR, found while re-deriving the rule from the speclet

Please hold off reviewing the current head - answering your question made me check the patch against the speclet rather than against my own test cases, and it is not correct for one class of constraints. I would rather report that myself than have you find it.

Measuring what the compiler actually requires, for a base method public virtual void M<T>(T? v) with the given constraint and an override with the given disambiguator:

base constraint required on the override
none, notnull, Enum, an interface, T : U (U class-constrained or unconstrained) where T : default
class, class?, a class type (e.g. Stream), Delegate, MulticastDelegate, T : U (U : Stream) where T : class
struct, unmanaged nothing

The patch derives the discriminator from ITypeParameter.HasReferenceTypeConstraint, which reflects only the metadata ReferenceTypeConstraint flag, that is, the class / class? keyword. For a class-type constraint it therefore emits default, which is CS8822, and the output still does not recompile. The restated class leaves no trace to read back either - for a base declaring where T : Stream, the override's type parameter is flags=None, constraints=[Stream] - so the discriminator has to be derived, not looked up.

ILSpy already has the right predicate: the tri-state IType.IsReferenceType, whose AbstractTypeParameter implementation carries the Object / ValueType / Enum carve-out and chains through T : U via EffectiveBaseClass.

This mirrors the wording of the compiler's own diagnostics and reproduces every row of the table above. I will push that shortly, together with fixture cases for a class-type constraint, Delegate, Enum, an interface constraint and both T : U directions, plus a nested-position case (the requirement applies to any occurrence of T? in the signature - T?[], List<T?>, a tuple element, ref / out, or the return type). I will also key the collector on type-parameter identity rather than owner kind plus index, so that a SpecializedMethod signature carrying a foreign type parameter of the same index cannot produce a spurious clause.

One residual I would leave unless you prefer it hardened: for constraint shapes only IL can express (a concrete delegate, array, struct or enum constraint - CS0701 / CS0706 in C#), IsReferenceType can differ from Roslyn's classification.

Language version

The clause is only emitted where a T? annotation on a non-value-type-constrained parameter is already being rendered, which is itself C# 9 and only happens when NullableReferenceTypes is enabled. Gating the constraint alone would therefore trade CS8400 for CS8627 / CS0453 and gain nothing. If you would like the setting to report honestly, the coherent change is for NullableReferenceTypes to imply C# 9 in GetMinimumRequiredVersion() - it maps to C# 8 today, so ILSpy can already emit C# 9-only T?. Happy to do that here or in a separate PR, whichever you prefer, and there is no urgency on my side.

@sailro
sailro force-pushed the fix-3909-default-constraint branch from 008cf7a to 65aef35 Compare July 25, 2026 14:35
@sailro

sailro commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Pushed the correction.

What changed. The discriminator is now derived from the tri-state ITypeParameter.IsReferenceType instead of the HasReferenceTypeConstraint / HasValueTypeConstraint metadata flags.

AbstractTypeParameter.IsReferenceType already implements the classification this needs, including the Object / ValueType / Enum carve-out and the chain through a type-parameter constraint via EffectiveBaseClass, so the three outcomes line up with the wording of the compiler's own diagnostics ("is not a reference type" in CS8665, "is constrained to a reference type or a value type" in CS8822).

The collector now matches on type-parameter identity rather than owner kind plus index, so a specialized signature carrying a foreign type parameter that shares an index with one of the method's own cannot produce a spurious clause.

Test coverage. The Issue3909 fixture gained a class-type constraint, Delegate, Enum, an interface constraint, both directions of a T : TOuter chain (TOuter : Node requiring class, TOuter : class requiring default), a List<T?> nested annotation, a two-type-parameter method where only one is annotated, and a control where the annotated parameter belongs to the containing type while the method has its own parameter at the same index.

Checked as a red/green pair: with the previous implementation the fixture fails on exactly the three class-type rows, for every compiler in the matrix:

-  public override T? ClassType<T>(T? value) where T : class
+  public override T? ClassType<T>(T? value) where T : default
-  public override T? DelegateType<T>(T? value) where T : class
+  public override T? DelegateType<T>(T? value) where T : default
-  public override T? Chained<T>(T? value) where T : class
+  public override T? Chained<T>(T? value) where T : default

Validation

  • Issue3909 Pretty matrix: 6 passed
  • full Pretty suite: 1,919 passed, 5 existing skips
  • full solution test suite: 4,927 passed, 15 existing skips, 0 failed
  • Release solution build: 0 warnings, 0 errors

A class-type constraint such as Stream or Delegate sets no
ReferenceTypeConstraint flag, so keying the disambiguator off that flag gave
those overrides the default constraint, which is CS8822, and the output still
did not recompile. The restated disambiguator leaves no metadata trace of its
own, so the choice has to follow from whether the inherited constraints make
the type parameter a reference type, a value type, or neither.

Matching the annotated type parameters by identity rather than by owner kind
and index also keeps a specialized signature from contributing a foreign type
parameter that happens to share an index.

Assisted-by: Copilot:claude-opus-5:GitHub Copilot CLI
@sailro
sailro force-pushed the fix-3909-default-constraint branch from 65aef35 to 0e60038 Compare July 25, 2026 14:37
IsReferenceType is a bool?, so choosing between class, default and no
constraint at all is a three-state decision. Spelling those states out keeps
that visible where the choice is made, rather than leaving it implied by a
comparison against true.

Assisted-by: Copilot:claude-opus-5:GitHub Copilot CLI

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a C# recompilation bug in the decompiler output for generic overrides and explicit interface implementations that use nullable-annotated type parameters (T?). It ensures ILSpy emits the required class / default disambiguator constraint so T? is parsed as a nullable annotation (not Nullable<T>), preventing signature mismatches and CS0115/CS0453 on recompile.

Changes:

  • Emit nullability disambiguator constraints (class or default) for affected method type parameters on overrides and explicit interface implementations.
  • Detect which method type parameters actually appear as T? anywhere in return/parameter types (including nested positions) and emit disambiguators only for those.
  • Add a new Pretty fixture and test runner entry covering the reported scenarios and edge cases.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
ICSharpCode.Decompiler/CSharp/Syntax/TypeSystemAstBuilder.cs Adds collection logic to detect nullable-annotated method type parameters and emit class/default constraints on overrides/explicit impls.
ICSharpCode.Decompiler.Tests/TestCases/Pretty/Issue3909.cs New Pretty fixture exercising nullable override/explicit-impl constraint disambiguation scenarios.
ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs Registers the new Pretty test case for Roslyn 3+ with nullable enabled.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@siegfriedpammer

siegfriedpammer commented Jul 26, 2026

Copy link
Copy Markdown
Member

Checked how this interacts with C# 13 allows ref struct (verified against csc and the compiled metadata):

  • allows ref struct is inherited implicitly on overrides/EIIs; restating it is CS0460, both alone and as where T : default, allows ref struct. The only legal form on a T? override is plain where T : default — which this PR emits, since such a type parameter has IsReferenceType == null. The class arm can never collide (class + allows ref struct is CS9243).
  • The correctness is one refactor away from breaking, though: Roslyn re-emits the byreflike flag on the override's own type parameter (Derived::M<byreflike T> in IL), and the general constraint printer emits allows ref struct from exactly that flag. The new path is safe only because it hand-builds the disambiguator constraint instead of going through ConvertTypeParameterConstraint.

Could you add a case to the fixture pinning this? E.g. under #if CS130 (the roslyn3OrNewerOptions set has no net40 configs, so that gating suffices):

public class BaseWithAllowsRefStruct
{
	public virtual void Annotated<T>(T? value) where T : allows ref struct
	{
	}

	public virtual void Plain<T>(T value) where T : allows ref struct
	{
	}
}

public class DerivedWithAllowsRefStruct : BaseWithAllowsRefStruct
{
	public override void Annotated<T>(T? value) where T : default
	{
	}

	public override void Plain<T>(T value)
	{
	}
}

This comment was written by an AI agent (Claude Code) operated by @siegfriedpammer.

allows ref struct is inherited implicitly, so restating it on an override is
CS0460 even alongside a legal disambiguator. Roslyn still re-emits the byreflike
flag on the override's own type parameter, and the general constraint printer
turns that flag back into source, so the disambiguator stays legal only as long
as it is built separately. Cover a C# 13 base whose annotated and plain methods
both allow ref structs.

Assisted-by: Copilot:claude-opus-5:GitHub Copilot CLI
@sailro

sailro commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, that is a good catch on the refactor hazard, and I reproduced your findings before acting on them.

Verified with csc (.NET 10 SDK, LangVersion latest) against a base declaring public virtual void M<T>(T? v) where T : allows ref struct:

override clause result
(none) CS0115, CS0453
where T : default OK
where T : allows ref struct CS0460 (+ CS0115, CS0453)
where T : default, allows ref struct CS0460
where T : class CS8665
where T : class, allows ref struct CS0460 (+ CS8665)

And class together with allows ref struct on a declaration is CS9243 ("Cannot allow ref structs for a type parameter known from other constraints to be a class"), so the class arm indeed cannot collide.

Your metadata point holds too. Reflecting over the compiled assembly, the flag is on the override just as it is on the base:

B.Annotated<T> flags=AllowByRefLike (raw=0x20)
D.Annotated<T> flags=AllowByRefLike (raw=0x20)

and ConvertTypeParameterConstraint prints allows ref struct from exactly that flag (tp.AllowsRefLikeType), so routing the disambiguator through it would emit CS0460.

Added your fixture case verbatim, under #if CS130 as you suggested. I checked that the block is genuinely exercised rather than passing vacuously: temporarily making the new path append allows ref struct when the flag is set turns the fixture red on exactly the four Roslyn 4.14 / latest configurations, with the diff

+ public override void Annotated<T>(T? value) where T : default, allows ref struct

while the two Roslyn 3.11 configurations stay green because CS130 is not defined there. That is the CS0460 form, so the case pins what you asked it to pin.

I also recorded the invariant next to the code, since the fixture alone does not explain why the clause is hand-built:

// The clause is built here rather than through ConvertTypeParameterConstraint, which also
// prints 'allows ref struct' from the byreflike flag. That flag is re-emitted on the
// override's own type parameter as well, and restating it is CS0460.

Pushed as a separate commit (ee2d747). Validation on the new head: full solution suite 4,927 passed, 15 existing skips, 0 failed; Debug and Release builds 0 warnings, 0 errors; formatting verified with the pinned dotnet-format from BuildTools/pre-commit.

The chain cases in the fixture route T : TOuter through a class-level
type parameter; the variant where the dependency target is a sibling
method type parameter (M<T, U> with T : U) was uncovered. It pins the
same alignment from a different angle: csc rejects 'class' with CS8665
when U is merely class-constrained and requires 'default', but accepts
'class' when U carries a class-type constraint, matching what the
tri-state IsReferenceType derives. Both directions were verified
against csc before adding the expected output.

Assisted-by: Claude:claude-fable-5:Claude Code
@siegfriedpammer
siegfriedpammer merged commit 486ff7f into icsharpcode:master Jul 27, 2026
7 checks passed
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.

Generic override loses where T : default and does not recompile

3 participants