If you’ve ever wished your C# code could precisely model “one of these types, but never anything else,” discriminated unions are about to make your day. They bring first-class, exhaustive, and ergonomic sum types to the language, unlocking safer domain modeling, clearer APIs, and fewer runtime surprises. In this post, we’ll explore what’s coming, why it matters, and how to use it effectively.
The features covered here are based on active C# language proposals. Several are LDM-approved but not yet shipped in a released C# version. Syntax and details may evolve. Links to the official proposals are included throughout.
What problems do discriminated unions solve?
- Exhaustive reasoning: The compiler can ensure you’ve handled every case in a
switch, removing fall-through bugs. - Precise domain modeling: Represent “either X or Y (or Z)” as a single type safely.
- Clearer APIs: Return a single result type instead of coupling exceptions, booleans, and out parameters.
- Fewer nulls and invalid states: Illegal states become unrepresentable.
If you’ve used OneOf<T...>, FluentResults, or hand-rolled patterns with records and pattern matching, unions make these scenarios first-class and more efficient.
The feature family at a glance
- Nominal type unions — declare a
unionby naming its allowed cases. (Approved) - Standard type unions — generic
System.Union<T1, T2, ...>building blocks. (Approved) - Union interfaces — identify union types at runtime and enable generic construction. (Approved)
- Custom unions — let your own types act like unions. (Approved)
- Non-boxing access pattern — performant access for custom unions. (Approved)
- Closed enums — prohibit non-declared values and enable exhaustive switches. (Approved)
- Closed hierarchies — restrict inheritance to within the assembly and enable exhaustive switches over derived types. (Approved)
- Case declarations — concise nested case syntax for closed types and unions. (Approved)
- Target-typed static member access — terse
.Caseaccess when the target type is known. (Approved) - Target-typed generic type inference, inference for constructor calls, and inference for type patterns — proposed, not approved at the time of writing.
See the official overview for the current status and relationships across features: Union Proposals Overview
Nominal type unions
Nominal unions let you define a named union that can only be one of a specified set of types.
public union Pet(Cat, Dog);
Pet pet = new Dog("Fido");
var sound = pet switch
{
Cat cat => cat.Meow(),
Dog dog => dog.Bark(),
}; // No default needed; exhaustiveness is guaranteed
The type system enforces that Pet is only a Cat or Dog, so your switch is provably exhaustive. This reduces error paths and “what-if” branches that never occur.
Tips
- Use nominal unions for domain types: commands, events, tokens, AST nodes, etc.
- Prefer small, intentional case sets; if the set is open-ended, consider closed hierarchies instead.
Standard type unions (System.Union<…>)
Standard unions are reusable generic unions in System you can adopt immediately for common patterns.
// A value that is either int or string
System.Union<int, string> numberOrText = 42;
var message = numberOrText switch
{
int i => $"int: {i}",
string s => $"string: {s}",
};
Use cases:
- Parsing and decoding results (e.g., number-or-string fields in JSON)
- API responses that may legitimately be one of a small set of shapes
- Interop where payloads vary by protocol/version
Union interfaces
Union interfaces allow runtime identification and generic operations over union values.
object obj = GetValue();
if (obj is IUnion u && u.Value is string s)
{
Console.WriteLine($"string: {s}");
}
void Handle<TUnion>() where TUnion : IUnion<TUnion>
{
object value = GetDynamicValue();
if (TUnion.TryCreate(value, out var union))
{
// Do something with `union` in a generic context
}
}
You’ll probably see these more in libraries and frameworks that need to work with unions generically.
Custom unions
You can make your own types behave like unions so they work seamlessly with pattern matching and exhaustiveness checks.
A minimal, illustrative example for an Option<T> (aka Maybe):
public readonly struct Option<T> // illustrative only
{
private readonly bool _hasValue;
private readonly T _value;
private Option(T value)
{
_hasValue = true;
_value = value;
}
public static Option<T> Some(T value) => new(value);
public static Option<T> None() => default;
// Non-boxing access pattern shape (illustrative)
public bool TryGetValue(out T value)
{
value = _value;
return _hasValue;
}
}
// Consumption reads naturally with pattern matching
Option<int> opt = Option<int>.Some(5);
var result = opt switch
{
{ } when opt.TryGetValue(out var v) => v,
_ => 0
};
Notes:
- The proposals define the recognition patterns; your custom type must match the shape the compiler understands.
- The non-boxing access pattern avoids allocations when extracting values in tight loops.
Closed enums
Closed enums prohibit values outside the declared set and enable truly exhaustive switches.
public closed enum Color { Red, Green, Blue }
// var invalid = Color.Red - 1; // Error: not a declared member
string name = color switch
{
Color.Red => "red",
Color.Green => "green",
Color.Blue => "blue",
}; // No default needed
Great for protocol/state-machine values where “unknown” is not meaningful in your domain.
Closed hierarchies
Closed classes prevent subclassing from other assemblies, enabling exhaustive reasoning across derived types.
// Assembly A
public closed abstract class Shape { }
public sealed class Circle(float radius) : Shape;
public sealed class Rect(float w, float h) : Shape;
// Assembly B
// public sealed class Triangle : Shape; // Error: cannot subclass closed type from another assembly
float Area(Shape s) => s switch
{
Circle c => MathF.PI * c.radius * c.radius,
Rect r => r.w * r.h,
}; // Exhaustive across known cases in the assembly that defines Shape
Choose closed hierarchies when you want polymorphism with a fixed, assembly-local set of subtypes.
Case declarations
Case declarations provide a concise way to nest “cases” under a closed type (records, classes) or unions.
public closed record GateState
{
case Closed;
case Locked;
case Open(float Percent);
}
string Render(GateState state) => state switch
{
GateState.Closed => "Closed",
GateState.Locked => "Locked",
GateState.Open(var p) => $"Open {p:P0}",
};
public union Pet
{
case Cat(string Name);
case Dog(string Name);
}
This keeps related variants together, emphasizes the closed nature of the set, and keeps call sites tidy.
Target-typed static member access
When the target type is known, you can omit the type name for static case access, producing compact pattern-matching code.
return result switch
{
.Success(var val) => val,
.Error => defaultValue,
};
Use this sparingly in codebases where the readability benefit is clear—newcomers may need a short adjustment period.
Designing a robust Result type with unions
A common and compelling use case is a Result<T> flow that encodes success and failure without exceptions for control flow.
Tiny contract
- Input: An operation that can succeed with
Tor fail with an error type. - Output: A union of cases that is exhaustively handled at call sites.
- Errors: Represented as a first-class case, not nulls or magic values.
- Success criteria: All call sites compile with exhaustive handling.
Example with System.Union (minimalist)
using Result = System.Union<Success, Error>;
public readonly record struct Success;
public readonly record struct Error(string Message);
Result DoWork(bool ok)
=> ok ? new Success() : new Error("Something went wrong");
string Handle() => DoWork(ok: true) switch
{
Success => "All good",
Error(var msg) => $"Failed: {msg}"
};
Example with nominal union and cases (expressive)
public union Result
{
case Success;
case Failure(string Message);
}
Result Save(Order order)
=> order.IsValid ? new Result.Success() : new Result.Failure("Invalid order");
string Report(Result result) => result switch
{
Result.Success => "Saved",
Result.Failure(var msg) => $"Failed: {msg}",
};
Practical scenarios you’ll actually use
- HTTP endpoints: Return “Created | ValidationError | Conflict | Accepted”. No more mixing exceptions, bools, and out params.
- Parsing: “Guid | int | string” inputs, handled exhaustively.
- State machines: Closed enums and case-declared records for clear transitions.
- Compiler/AST: Closed hierarchies for node types; exhaustive visitors via pattern matching.
- Interop: Standard unions for wire formats with versioned variants.
Interop with existing C# features
- Records: Case declarations play nicely with records; pattern matching extracts components naturally.
- Nullability: Prefer explicit “None”/case types to
null. If you accept nullable input, normalize into a union early. - Generics: Standard unions give you generic power; nominal unions give you domain identity and readability.
- Pattern matching: Switch expressions become the idiomatic consumer for unions.
Performance considerations
- Pattern matching on unions is compile-time directed; avoid needless allocation by leveraging non-boxing access patterns for custom unions.
- For hot paths, prefer value types for case payloads when appropriate; measure with BenchmarkDotNet.
- Overuse of deeply nested unions can increase code size; keep sets minimal and compose thoughtfully.
Migration guidance (from OneOf/FluentResults/etc.)
- Replace OneOf<T…> with
System.Union<T...>as a drop-in, then incrementally move critical domains to nominal unions for readability and API clarity. - Replace
bool TryX(out T)patterns with union-returning methods; the compiler will enforce exhaustive handling at call sites. - When porting libraries, consider exposing union interfaces for advanced consumers.
Versioning and evolution
- Adding a new union case is a source-breaking change for exhaustive consumers; plan major versions accordingly.
- For wire protocols, prefer closed enums and closed hierarchies to enforce stability, or introduce a distinct “Unknown” case explicitly.
Gotchas and guardrails
- Don’t use unions as a dumping ground; keep case sets cohesive and minimal.
- Prefer enums or closed hierarchies when identity and behavior dominate over “either/or” data shapes.
- Be explicit about serialization contracts; ensure your serializer understands your chosen union representation.
- Keep readability paramount; target-typed static member access is powerful but can be cryptic if overused.
Where to learn more
- Union Proposals Overview
- Nominal Type Unions
- Standard Type Unions
- Union Interfaces
- Custom Unions
- Non-boxing Access Pattern for Custom Unions
- Closed Enums
- Closed Hierarchies
- Case Declarations
- Target-typed Static Member Access
- Target-typed Generic Type Inference — not approved
- Inference for Constructor Calls — not approved
- Inference for Type Patterns — not approved
Conclusion
Discriminated unions bring expressive, reliable, and maintainable modeling to C#. They make domain constraints explicit, push correctness to compile time, and clean up error-prone control flow. Whether you start small with System.Union<T1, T2> or go all-in with nominal unions and case declarations, the payoff is immediate: simpler code, safer refactors, and fewer runtime surprises.

