You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This proposal was drafted with assistance from GitHub Copilot (Claude Opus 4.7). Content has been reviewed by the issue author.
Background and motivation
The C# union types proposal introduces a structural pattern that any class or struct may follow to be treated as a union:
a public instance Value property of type object, and
a set of public single-parameter creation members (constructors, or static Create methods on a nested IUnionMembers provider interface).
Frameworks that need to operate over arbitrary union types — serializers, schema exporters, model binders, source generators with reflection fallback, validators — must reflect over this convention themselves. System.Text.Json already ships this discovery code (see DefaultJsonTypeInfoResolver.Union.cs) and replicates several non-obvious rules: Nullable<T> parameter unwrapping, NRT consultation via NullabilityInfoContext, case deduplication, topologically-sorted dispatch, TryGetValue overload matching.
Every consumer of this convention will need the same logic. This proposal adds runtime metadata APIs that surface union-type information, modeled on the existing NullabilityInfoContext pair, so that this logic lives once in System.Reflection.
Existing workarounds:
Roll the discovery code locally (what STJ does today). Fragile — small inconsistencies in Nullable<T> unwrapping or duplicate-case OR-ing produce divergent behavior across consumers.
Annotate every union manually (e.g. via custom attributes). Pushes the cost onto union authors and doesn't compose with the language-level convention.
namespaceSystem.Reflection;publicsealedclassUnionInfoContext{publicUnionInfoContext();// Fast structural probe; does not allocate metadata.[RequiresUnreferencedCode("...")]publicstaticboolIsUnion([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors|DynamicallyAccessedMemberTypes.PublicMethods|DynamicallyAccessedMemberTypes.PublicProperties|DynamicallyAccessedMemberTypes.PublicNestedTypes|DynamicallyAccessedMemberTypes.Interfaces)]Typetype);// Cached on this context. Throws ArgumentException if not a union.[RequiresUnreferencedCode("...")]publicUnionInfoCreate([DynamicallyAccessedMembers(...)]Typetype);[RequiresUnreferencedCode("...")]publicboolTryCreate([DynamicallyAccessedMembers(...)]Typetype,[NotNullWhen(true)]outUnionInfo?unionInfo);}publicsealedclassUnionInfo{publicTypeType{get;}publicTypeUnionDefiningType{get;}// = Type, or nested IUnionMembers interfacepublicboolHasUnionAttribute{get;}// [Union] is structural, not requiredpublicPropertyInfoValueProperty{get;}// public instance `object Value { get; }`publicIReadOnlyList<UnionCaseInfo>Cases{get;}// declaration order, deduplicated}publicsealedclassUnionCaseInfo{publicUnionInfoDeclaringUnion{get;}publicTypeCaseType{get;}// `Nullable<T>` unwrapped to `T`publicboolAdmitsNull{get;}// OR'd across deduplicated overloadspublicMemberInfoCreationMember{get;}// ConstructorInfo or static MethodInfopublicMethodInfo?TryGetValueMethod{get;}// bool TryGetValue(out T value), if any}publicsealedclassUnionAccessors<TUnion>{[RequiresDynamicCode("...")][RequiresUnreferencedCode("...")]publicstaticUnionAccessors<TUnion>Create(UnionInfoinfo);publicUnionInfoInfo{get;}publicFunc<TUnion,(Type?CaseType,object?Value)>Deconstructor{get;}publicFunc<Type?,object?,TUnion>Constructor{get;}publicUnionCaseInfo?ResolveCase(TyperuntimeType);}
Compiled accessors for repeated use (serializer-style):
UnionInfoinfo=context.Create(typeof(MyUnion));UnionAccessors<MyUnion>accessors=UnionAccessors<MyUnion>.Create(info);// Deconstruct an instance — returns the matched declared case and its value.(Type?caseType,object?value)=accessors.Deconstructor(myUnion);// Construct an instance from a case value, inferring the case type.MyUnionunion=accessors.Constructor(null,"hello");
Migrating STJ's internal resolver (sketch):
internalstaticvoidPopulateUnionMetadata(JsonTypeInfotypeInfo){if(!UnionInfoContext.IsUnion(typeInfo.Type))return;UnionInfoinfo=newUnionInfoContext().Create(typeInfo.Type);foreach(UnionCaseInfocininfo.Cases){typeInfo.UnionCases.Add(newJsonUnionCaseInfo(c.CaseType,c.AdmitsNull));}// ... delegate construction wraps UnionAccessors<TUnion>}
Alternative Designs
Static UnionInfo.Create(Type) vs context-based discovery. The proposal mirrors NullabilityInfoContext so that NRT lookups (which themselves require an NullabilityInfoContext) can be amortized across many union types. A static convenience could be added later if requested.
Non-generic UnionAccessors (no <TUnion>). Considered. The generic form keeps the Deconstructor/Constructor delegates strongly-typed, which matters most to serializer hot paths. A non-generic boxed-Type entry point can be added if a real scenario surfaces.
Deconstructor returning a dedicated UnionValue struct instead of a named tuple. The named tuple is consistent with similar deconstruction-style APIs (e.g. KeyValuePair<TKey,TValue> consumers) and avoids a single-use type. Reviewers may prefer a dedicated struct for evolution headroom; happy to switch.
Surfacing IUnionMembers provider shape vs. ctor-only. The spec explicitly allows the provider shape (a nested public interface declaring Create factories and Value). Supporting only ctors would break unions that delegate construction to a partial provider. Discovery here covers both.
AdmitsNull semantics for reference types. Computed via NullabilityInfoContext on the parameter. For value types it follows Nullable<T> unwrapping. Where the same case type appears across multiple ctor overloads (only possible for value-type cases in C#), the flag is the logical OR.
Open question: multiple nullable cases. When a union has more than one nullable case and its Value is null, the structural pattern cannot recover which case was originally constructed. The proposal returns the first declared nullable case in that scenario and documents the constraint. STJ has the same limitation.
Note
This proposal was drafted with assistance from GitHub Copilot (Claude Opus 4.7). Content has been reviewed by the issue author.
Background and motivation
The C# union types proposal introduces a structural pattern that any class or struct may follow to be treated as a union:
Valueproperty of typeobject, andCreatemethods on a nestedIUnionMembersprovider interface).Frameworks that need to operate over arbitrary union types — serializers, schema exporters, model binders, source generators with reflection fallback, validators — must reflect over this convention themselves.
System.Text.Jsonalready ships this discovery code (seeDefaultJsonTypeInfoResolver.Union.cs) and replicates several non-obvious rules:Nullable<T>parameter unwrapping, NRT consultation viaNullabilityInfoContext, case deduplication, topologically-sorted dispatch,TryGetValueoverload matching.Every consumer of this convention will need the same logic. This proposal adds runtime metadata APIs that surface union-type information, modeled on the existing
NullabilityInfoContextpair, so that this logic lives once inSystem.Reflection.Existing workarounds:
Nullable<T>unwrapping or duplicate-case OR-ing produce divergent behavior across consumers.Related: #125449 (STJ union user story).
API Proposal
Prototype: eiriktsarpalis@1e35a29
API Usage
Detect and inspect a union type:
Compiled accessors for repeated use (serializer-style):
Migrating STJ's internal resolver (sketch):
Alternative Designs
Static
UnionInfo.Create(Type)vs context-based discovery. The proposal mirrorsNullabilityInfoContextso that NRT lookups (which themselves require anNullabilityInfoContext) can be amortized across many union types. A static convenience could be added later if requested.Non-generic
UnionAccessors(no<TUnion>). Considered. The generic form keeps theDeconstructor/Constructordelegates strongly-typed, which matters most to serializer hot paths. A non-generic boxed-Typeentry point can be added if a real scenario surfaces.Deconstructorreturning a dedicatedUnionValuestruct instead of a named tuple. The named tuple is consistent with similar deconstruction-style APIs (e.g.KeyValuePair<TKey,TValue>consumers) and avoids a single-use type. Reviewers may prefer a dedicated struct for evolution headroom; happy to switch.Surfacing
IUnionMembersprovider shape vs. ctor-only. The spec explicitly allows the provider shape (a nested public interface declaringCreatefactories andValue). Supporting only ctors would break unions that delegate construction to a partial provider. Discovery here covers both.AdmitsNullsemantics for reference types. Computed viaNullabilityInfoContexton the parameter. For value types it followsNullable<T>unwrapping. Where the same case type appears across multiple ctor overloads (only possible for value-type cases in C#), the flag is the logical OR.Open question: multiple nullable cases. When a union has more than one nullable case and its
Valueisnull, the structural pattern cannot recover which case was originally constructed. The proposal returns the first declared nullable case in that scenario and documents the constraint. STJ has the same limitation.