dotnet / dotnet/runtime

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

Open
#128,549 7 comments 0 reactions 0 assignees View on GitHub
api-suggestion area-System.Reflection
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

> [!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](https://github.com/dotnet/csharplang/blob/main/proposals/unions.md) 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`](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Union.cs)) and replicates several non-obvious rules: `Nullable` 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` 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: dotnet/runtime#125449 (STJ union user story).

## API Proposal

```csharp
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 Cases { get; } // declaration order, deduplicated
}

public sealed class UnionCaseInfo
{
public UnionInfo DeclaringUnion { get; }
public Type CaseType { get; } // `Nullable` 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
{
[RequiresDynamicCode("...")]
[RequiresUnreferencedCode("...")]
public static UnionAccessors Create(UnionInfo info);

public UnionInfo Info { get; }
public Func Deconstructor { get; }
public Func Constructor { get; }
public UnionCaseInfo? ResolveCase(Type runtimeType);
}
```

Prototype: https://github.com/eiriktsarpalis/runtime/commit/1e35a29fb7fb72fa982c6e58b492d24526017abd

## API Usage

**Detect and inspect a union type:**

```csharp
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):**

```csharp
UnionInfo info = context.Create(typeof(MyUnion));
UnionAccessors accessors = UnionAccessors.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):**

```csharp
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
}
```

## 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 ``).** 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` 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` 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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.