Skip to content

[API Proposal]: System.Reflection union types metadata APIs #128549

@eiriktsarpalis

Description

@eiriktsarpalis

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:

  • 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.

Related: #125449 (STJ union user story).

API Proposal

namespace System.Reflection;

public sealed class UnionInfoContext
{
    public UnionInfoContext();

    // Fast structural probe; does not allocate metadata.
    [RequiresUnreferencedCode("...")]
    public static bool IsUnion(
        [DynamicallyAccessedMembers(
            DynamicallyAccessedMemberTypes.PublicConstructors |
            DynamicallyAccessedMemberTypes.PublicMethods |
            DynamicallyAccessedMemberTypes.PublicProperties |
            DynamicallyAccessedMemberTypes.PublicNestedTypes |
            DynamicallyAccessedMemberTypes.Interfaces)] Type type);

    // Cached on this context. Throws ArgumentException if not a union.
    [RequiresUnreferencedCode("...")]
    public UnionInfo Create([DynamicallyAccessedMembers(...)] Type type);

    [RequiresUnreferencedCode("...")]
    public bool TryCreate(
        [DynamicallyAccessedMembers(...)] Type type,
        [NotNullWhen(true)] out UnionInfo? unionInfo);
}

public sealed class UnionInfo
{
    public Type Type { get; }
    public Type UnionDefiningType { get; }            // = Type, or nested IUnionMembers interface
    public bool HasUnionAttribute { get; }            // [Union] is structural, not required
    public PropertyInfo ValueProperty { get; }        // public instance `object Value { get; }`
    public IReadOnlyList<UnionCaseInfo> Cases { get; } // declaration order, deduplicated
}

public sealed class UnionCaseInfo
{
    public UnionInfo DeclaringUnion { get; }
    public Type CaseType { get; }                     // `Nullable<T>` unwrapped to `T`
    public bool AdmitsNull { get; }                   // OR'd across deduplicated overloads
    public MemberInfo CreationMember { get; }         // ConstructorInfo or static MethodInfo
    public MethodInfo? TryGetValueMethod { get; }     // bool TryGetValue(out T value), if any
}

public sealed class UnionAccessors<TUnion>
{
    [RequiresDynamicCode("...")]
    [RequiresUnreferencedCode("...")]
    public static UnionAccessors<TUnion> Create(UnionInfo info);

    public UnionInfo Info { get; }
    public Func<TUnion, (Type? CaseType, object? Value)> Deconstructor { get; }
    public Func<Type?, object?, TUnion> Constructor { get; }
    public UnionCaseInfo? ResolveCase(Type runtimeType);
}

Prototype: eiriktsarpalis@1e35a29

API Usage

Detect and inspect a union type:

UnionInfoContext context = new();

if (context.TryCreate(typeof(StringOrInt), out UnionInfo? info))
{
    foreach (UnionCaseInfo c in info.Cases)
    {
        Console.WriteLine($"{c.CaseType.Name} (admits null: {c.AdmitsNull})");
    }
}

Compiled accessors for repeated use (serializer-style):

UnionInfo info = 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.
MyUnion union = accessors.Constructor(null, "hello");

Migrating STJ's internal resolver (sketch):

internal static void PopulateUnionMetadata(JsonTypeInfo typeInfo)
{
    if (!UnionInfoContext.IsUnion(typeInfo.Type)) return;

    UnionInfo info = new UnionInfoContext().Create(typeInfo.Type);
    foreach (UnionCaseInfo c in info.Cases)
    {
        typeInfo.UnionCases.Add(new JsonUnionCaseInfo(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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    api-suggestionEarly API idea and discussion, it is NOT ready for implementationarea-System.ReflectionuntriagedNew issue has not been triaged by the area owner

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions