devtake.dev

C# is getting union types in version 15. The preview shipped in .NET 11 Preview 2.

Mads Torgersen's union proposal landed in .NET 11 Preview 2 on April 2. C# 15 targets November 2026 and replaces the OneOf library hack the .NET community has been living with.

Soren Vanek · · 6 min read · 4 sources
C# 15 union types announcement graphic from the Microsoft .NET Blog
Image: Microsoft · Source

Mads Torgersen has been promising C# union types since 2017. The wait broke on April 2, when Microsoft shipped the first cut in .NET 11 Preview 2. The full feature targets C# 15 in November. The OneOf workaround the .NET community has been living with for eight years has an end date.

This matters past the .NET niche. C# is the third mainstream curly-brace language to commit a discriminated union design to a shipping compiler this decade, after Swift in 2014 and Rust in its 1.0 release. Java’s Project Amber sealed-class proposal solved an adjacent problem but not the same one. The C# team picked a syntax that looks closer to TypeScript than F#, which is the bet that most C# developers will reach for the feature having seen TypeScript’s A | B first, not having seen F#‘s Pet = Cat of ... | Dog of ... ever.

What the syntax looks like

Bill Wagner, C# / .NET Principal Content Developer, demonstrated the form in the announcement post:

public union Pet(Cat, Dog, Bird);

That declaration composes three existing types into a single closed type. There’s no shared base class, no requirement that Cat, Dog, and Bird live in the same assembly, no constructor case names. The compiler tracks which case a Pet holds and forces exhaustive switching when you read it.

string Describe(Pet pet) => pet switch
{
    Cat c => $"meow {c.Name}",
    Dog d => $"woof {d.Name}",
    Bird b => $"chirp {b.Name}",
};

If you forget a case, the compiler refuses. If a fourth case (Hamster) gets added to Pet six months from now, every call site fails to build until you handle it. That’s the property that the OneOf source-generator hack has been faking for years.

Andrew Lock’s walkthrough of the preview goes further: union bodies can declare their own members (“when every case carries a .Name, you can write pet.Name directly”), unions can be generic, and the switch syntax falls back to the existing pattern-matching machine you already know. The boxing question is the load-bearing piece for value types: C# unions use a non-boxing access pattern that surfaces via TryGetValue methods generated at the union site, not at the case site.

Why this took eight years

Torgersen first floated the idea on the C# language design notes in 2017. The proposal moved in 2018, stalled in 2019 over the boxing question, restarted in 2020, then stalled again on whether the feature should reuse F# discriminated unions’ runtime or build its own. The 2022 design pivoted on Mads’s call to make it a language feature that composes existing types instead of a new type declaration, which is what the April 2026 preview reflects.

The community response has been favorable, with caveats. NDepend’s preview review calls out that “generic unions and union bodies turn the feature into something actually useful for everyday API design,” and notes that “pattern matching ended up a touch nicer than expected, since you switch on the union directly rather than on its Value.” The chief outstanding question, per the same review, is interop: a function that returns Pet is fine in pure C#, but how it serializes across an HTTP boundary or shows up in a Swagger document is still being designed.

What’s still in preview

Preview 2 ships the syntax, the compiler enforcement, and the switch integration. It doesn’t ship the runtime types. The UnionAttribute annotation and IUnion interface that the generated code references must be declared in your project today; the Microsoft Learn proposal page confirms they’ll move into the runtime in a later .NET 11 preview. Source-generator interop is partial. Custom storage providers, which would let a library author optimize the byte layout for a specific union, are not in Preview 2.

The proposal status is “preview,” not “draft.” That’s the C# team’s signal that the language design is locked, not the implementation. Expect minor adjustments to attribute names and member provider semantics between now and November, not to the union keyword itself.

What this means for you

If you write C# for a living, the practical timeline is short. Preview 2 is available today. Install the .NET 11 Preview SDK, set <LangVersion>preview</LangVersion> in the csproj, and the keyword works. The realistic migration path for production code is two steps. Step one is wait for .NET 11 Release Candidate in September, when the runtime attributes ship. Step two is replace OneOf, Dunet, and hand-rolled “result type” abstractions with the language feature, one bounded subsystem at a time. The pattern-matching syntax stays exactly the same, so call sites barely move.

If you’re a library author, the design pressure points are clearer. Public APIs that return OneOf<TSuccess, TError> today should plan a major-version bump that uses the language union keyword instead. The two coexist, but a OneOf-typed return in 2027 will read as legacy code. The faster you offer the new shape, the less of your downstream surface needs to migrate at the same time.

The other shift is downstream, on the F#-curious-but-not-yet C# crowd. Pattern matching plus discriminated unions are two of the three things F# advocates point at when they pitch the language to a C# team. The third is immutability by default, which C# has been edging toward through records and required init. C# 15 closes the gap by enough that the F# pitch gets a lot harder to make on technical grounds in a mostly-C# shop, and the .NET ecosystem’s center of gravity stays where it already is.

How it compares to neighbours

TypeScript’s union syntax is the closest analogue. There, type Pet = Cat | Dog | Bird is a structural union: any value whose shape matches any case is assignable. TypeScript’s runtime does nothing about it. The discrimination is the programmer’s job. C# is taking the opposite call. A union Pet(Cat, Dog, Bird) is nominal. The compiler stamps each case with a tag at construction time, and the case is the only way back out. You cannot accidentally assign a Cat to a Pet variable through duck typing. That’s a feature for production code and a friction for prototype-shaped TypeScript ports.

Rust’s enum with associated data is the other comparison. Rust’s compiler enforces exhaustive matching the same way C#‘s will, and Rust’s enums avoid boxing the same way C#‘s unions plan to. The difference is in case construction. Rust forces you to name each case (Pet::Cat(c)) at construction. C# lets you assign a Cat instance directly to a Pet variable via implicit conversion. That’s a real ergonomic win when most of your codebase already produces concrete typed values, and a real footgun when two case types happen to share a structural shape.

F# discriminated unions are the in-family relative. Same .NET runtime, different design pressure. F# DUs declare the case constructors at the same site as the type itself, which gives you tight pattern matching and zero ambiguity at the cost of having to define new constructor names. C# unions skip that step entirely. Bill Wagner’s framing in the Microsoft announcement is that union types should “compose existing types” rather than introduce new ones. Pragmatic. Familiar to TypeScript devs. Less elegant on paper.

Where the design still hurts

The interop story is unresolved. A C# library that exposes a public method returning Pet serializes through the same JSON layer as any other type, but System.Text.Json’s source generator doesn’t know how to round-trip a union case discriminator without per-union configuration. Swagger and OpenAPI generation are in a similar state. ASP.NET model binding for inbound request bodies that carry unions is still in design.

Generic constraints are the second open question. The proposal allows generic unions today. Constraints on the case types (where T : IComparable) are partial in Preview 2, and the team has signaled they’ll firm up before stable. Library authors who want to ship constrained unions in October Preview 5 should expect at least one breaking syntax change.

Null is the third. The current design carries null as a value the compiler can route through a union, but nullable reference type annotations interact with union case types in ways the spec doesn’t fully cover yet. Expect a clarifying proposal before November.

None of these are blockers. They’re the normal “we shipped the language feature, the ecosystem catches up” tail that every C# release runs. Generic math took two releases to stabilise. Required init took three. Unions are likely to take one and a half. The compiler bits are the hard part, and those are done.

Share this article

Quick reference

discriminated union
A type that can hold a value from one of a fixed, known set of other types, with the compiler enforcing that every case is handled when reading the value.

Sources

Frequently Asked

Is this the same as F# discriminated unions?
Conceptually yes, mechanically no. F# DUs are nominal type definitions with their own case constructors. C# unions compose existing types you already have, with no shared base class required. The mental model is closer to TypeScript's `A | B | C` than to F#'s `type Pet = Cat of ... | Dog of ...`.
Will this replace the OneOf library?
For most cases, yes. OneOf and similar libraries (UnionTypes.Generators, Dunet) bolt the pattern on with source generators and runtime boxing. The compiler version avoids both. Existing OneOf<T1,T2,T3> code keeps compiling, but new code will reach for the language feature.
What needs to land before C# 15 ships?
The union member providers (custom storage layouts), full source-generator integration, and runtime `UnionAttribute` / `IUnion` types still need to ship. Preview 2 makes you declare those attributes manually. Later .NET 11 previews are expected to include them in the runtime.
Does this work in older .NET targets?
No. The feature requires the .NET 11 runtime for the underlying attribute and interface support. Multi-targeted libraries can polyfill the attribute for older TFMs at the cost of source generator gymnastics, but the cleanest path is wait for .NET 11 stable.

Mentioned in this article