{"slug": "net-ok-c-gets-union-types", "title": ".NET (OK, C#) gets union types", "summary": "The article announces that union types are being introduced in .NET 11 (C# 15), allowing developers to define types that can represent one of several unrelated types using the new `union` keyword. It explains how unions work, including implicit conversion, the `IUnion` interface, and pattern matching with switch expressions, while noting that the feature is based on .NET 11 preview 4 and may change before the final release.", "body_md": "Unions are one of those features that have been requested for years, and in .NET 11 (or rather, C# 15) they're *finally* here. In this post I describe what that support looks like, how you can use them, how they're implemented, and how you can implement your own custom types.\n\nThis post was written using the features available in .NET 11 preview 4. Many things may change between now and the final release of .NET 11.\n\n[What are union types?](#what-are-union-types-)\n\nUnions are one of those basic data structures which are used all the time in the functional programming world; they're available in F#, TypeScript, Rust…pretty much any functional-first language. There are many different *types* of union, but at their core they allow having a type that can represent two different things.\n\nSome of the simplest union types are the `Option<T>`\n\nand `Result<TSuccess, TError>`\n\ntypes. There's no \"standard\" version of these, but it's *super* common to see custom implementations. `Result<>`\n\nis one of the easiest to explain as it can be in one of two states:\n\n- Success—in this case the\n`Result<>`\n\nobject contains a`TSuccess`\n\nvalue representing the \"success\" result for an operation that succeeded. - Error—in this case the\n`Result<>`\n\nobject contains a`TError`\n\nvalue representing the \"error\" for an operation that failed.\n\nYou return a `Result<>`\n\nobject from your method, and then the caller has to *explicitly* handle both cases instead of assuming success.\n\nThis pattern is often called the result pattern and it has both pros and cons in C#. I wrote a series about using this pattern,\n\n[as well as considering whether it's worth it here].\n\nUnion types don't have to be the super generic form like this though. They can be used to represent any arbitrary combined set of types.\n\n[Union types in C# 15 with the ](#union-types-in-c-15-with-the-union-keyword)`union`\n\nkeyword\n\n`union`\n\nkeywordIn the previous section I used the classic `Result<>`\n\ntype as an example of a union, but unions are far more versatile than that. They're ideal whenever you want to deal with data that could be one of several potentially unrelated types.\n\nFor example, imagine we have three different `record`\n\ntypes, containing different properties, representing Operating Systems:\n\n```\npublic record Windows(string Version);\npublic record Linux(string Distro, string Version);\npublic record MacOS(string Name, int Version);\n```\n\nNote that these types *don't* have any values in common. Prior to C# 15, the main options for handling something which could be a `Windows`\n\n*or* `Linux`\n\n*or* `MaxOS`\n\nobject would be:\n\n- Try to create a base class from which all the types derive. That\n*might*work, but what if you don't control these types because they come from a library? - Store the type in an\n`object`\n\ninstance. This works, but you lose all the safety of working with types in this case. - Use some \"tag\" value for keeping track of which type your object contains, e.g. using an\n`enum`\n\nto track this.\n\nIn C# 15, we get direct support for this scenario with the `union`\n\nkeyword, as shown below:\n\n```\n//     👇 Use `union` as the type\npublic union SupportedOS(Windows, Linux, MacOS);\n//             👆 List the types that are part of the union\n```\n\nYou can create an instance of the `SupportedOS`\n\ntype in a couple of ways:\n\n```\n// You can call new and pass in an instance\nSupportedOS os = new SupportedOS(new MacOS(\"Tahoe\", 25));\n\n// Or you can use implict conversion (which calls new() behind the scenes)\nSupportedOS os = new MacOS(\"Tahoe\", 25);\n```\n\nThe generated `union`\n\ntype implements the `IUnion`\n\ninterface:\n\n```\npublic interface IUnion\n{\n    object? Value { get; }\n}\n```\n\nso you can always get the \"inner\" case value back out as an `object?`\n\nif you need to:\n\n```\n// You can access the stored \"inner\" object using `.Value`\nConsole.WriteLine(os.Value); // MacOS { Name = Tahoe, Version = 25 }\n```\n\nHowever, the canonical way to work with unions is to use a `switch`\n\nexpression:\n\n``` js\nstring GetDescription(SupportedOS os) => os switch\n{\n    Windows windows => $\"Windows {windows.Version}\",\n    Linux linux => $\"{linux.Distro} {linux.Version}\",\n    MacOS macOS => $\"MacOS {macOS.Name} ({macOS.Version})\",\n}; // note: no discard _ required\n```\n\nThe `switch`\n\nexpression automatically extracts the inner case type, and a very neat thing is that you *don't* need to include the `_ => `\n\n\"discard\" case either: the compiler enforces that you check for each of the allowed values, but you *only* need to check these values. And if you forget one, you'll get a warning:\n\n```\nwarning CS8509: The switch expression does not handle all possible values of its input type\n(it is not exhaustive). For example, the pattern 'MacOS' is not covered.\n```\n\nNote that if one of your case types is nullable, e.g.\n\n`MacOS?`\n\nthen you'll need to handle`null`\n\nin your`switch`\n\nexpressions too.\n\nTo come full circle, we could perhaps implement the `Result<>`\n\ntype as the following (just an example, there's lots of different implementations we could choose!)\n\n```\npublic union Result<T>(T, Exception);\n```\n\nor to show another classic, the `Option<T>`\n\ntype:\n\n```\npublic record class None;\npublic union Option<T>(None, T);\n```\n\nThat's the basics of the `union`\n\ntypes in C# 15, so next we'll look at how you can use them today, before we look behind the scenes at how they're implemented.\n\n[Using ](#using-union-types-in-net-11)`union`\n\ntypes in .NET 11\n\n`union`\n\ntypes in .NET 11To use `union`\n\ntypes you need to do two things:\n\n- Install .NET 11 preview 2+ SDK. The initial\n`union`\n\nsupport was added in preview 2, but you'll have a smoother experience if you install preview 4+. - Enable preview language support in your .csproj files, by adding\n`<LangVersion>preview</LangVersion>`\n\n```\n<Project Sdk=\"Microsoft.NET.Sdk\">\n\n  <PropertyGroup>\n    <OutputType>Exe</OutputType>\n\n    <!-- 👇 Add this -->\n    <LangVersion>preview</LangVersion>\n\n    <TargetFrameworks>net11.0;net8.0;net48</TargetFrameworks>\n    <ImplicitUsings>enable</ImplicitUsings>\n    <Nullable>enable</Nullable>\n  </PropertyGroup>\n\n</Project>\n```\n\nNote that although you need to use the .NET 11 SDK, you *can* target earlier versions of the runtime, such as I'm doing in the above *.csproj* file. The `union`\n\nsupport is implemented as a compiler feature, so it's available on earlier runtimes ([even if it's not technically supported on them](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/language-versioning)).\n\nHowever, if you're targeting earlier runtimes (or you're using .NET 11 preview 2 or preview 3), then you'll *also* need to add some helper types to your project:\n\n```\n#if !NET11_0_OR_GREATER\nnamespace System.Runtime.CompilerServices;\n\n[AttributeUsage(Class | Struct, AllowMultiple = false, Inherited = false)]\npublic sealed class UnionAttribute : Attribute;\n\npublic interface IUnion\n{\n    object? Value { get; }\n}\n```\n\n[These were added](https://github.com/dotnet/runtime/pull/127001) to .NET 11 in preview 4, so they'll be available automatically if you're using a newer SDK, but you'll need to include them if you're targeting earlier runtimes, regardless.\n\nAs you might have guessed, when the compiler creates the `union`\n\ntypes, it uses this attribute and implements this interface. In the next section we'll take a look at what the generated code looks like, to understand how the `union`\n\ntypes are implemented.\n\nIn terms of IDE support, if you're using either Visual Studio Preview, or VS Code's C# DevKit Insiders, then you should have initial support. [Support for JetBrains Rider is still pending](https://youtrack.jetbrains.com/projects/RIDER/issues/RIDER-135866/ETA-for-net11-preview-1-support).\n\n[How are ](#how-are-union-types-implemented)`union`\n\ntypes implemented\n\n`union`\n\ntypes implementedYou can see the full spec for `union`\n\ntypes [here](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/unions), but the standard generated code is really pretty simple:\n\n```\nusing System.Runtime.CompilerServices;\n\n[Union]\npublic struct SupportedOS : IUnion\n{\n    public object? Value { get; }\n\n    // Constructors for each case type\n    public SupportedOS(Windows value) => this.Value = (object) value;\n    public SupportedOS(Linux value) => this.Value = (object) value;\n    public SupportedOS(MacOS value) => this.Value = (object) value;\n}\n```\n\nAs you can see, the generated `SupportedOS`\n\ntype:\n\n- Is a\n`struct`\n\n, decorated with the`[Union]`\n\nattribute. - Has a single, readonly,\n`object? Value`\n\nproperty, implementing the`IUnion`\n\ninterface. - Has a constructor for each of the case types it supports.\n\nI was somewhat surprised to find there was no implicit conversion from the case types to the `SupportedOS`\n\ntype, given that we can write code like this:\n\n```\nSupportedOS os = new MacOS(\"Tahoe\", 25);\n```\n\nHowever it looks like the compiler simply rewrites this to use the `[Union]`\n\nconstructor:\n\n```\n// SupportedOS os = new MacOS(\"Tahoe\", 25);\n\n// The compiler emits code that looks like this:\nSupportedOS os = new SupportedOS(new MacOS(\"Tahoe\", 25));\n```\n\nThis implicit conversion is all driven by the `[Union]`\n\nattribute. You can see this in action if we rewrite our example to *not* use the `union`\n\nkeyword, and instead use the implementation code shown previously but we \"forget\" to include the `[Union]`\n\nattribute:\n\n```\nusing System.Runtime.CompilerServices;\n\nSupportedOS os = new MacOS(\"Tahoe\", 25); // Cannot implicitly convert type 'MacOS' to 'SupportedOS'\n\nvar description = os switch\n{\n    Windows windows => $\"Windows {windows.Version}\",        // An expression of type 'SupportedOS' cannot be handled by a pattern of type 'Windows'\n    Linux linux => $\"{linux.Distro} {linux.Version}\",       // An expression of type 'SupportedOS' cannot be handled by a pattern of type 'Linux'\n    MacOS macOS => $\"MacOS {macOS.Name} ({macOS.Version})\", // An expression of type 'SupportedOS' cannot be handled by a pattern of type 'MacOS'\n};\n\npublic record Windows(string Version);\npublic record Linux(string Distro, string Version);\npublic record MacOS(string Name, int Version);\n\n// 👇 This attribute is required to be a valid Union type,\n//     just removed here for demo purposes\n// [Union] \npublic struct SupportedOS : IUnion\n{\n    public object? Value { get; }\n\n    public SupportedOS(Windows value) => this.Value = (object) value;\n    public SupportedOS(Linux value) => this.Value = (object) value;\n    public SupportedOS(MacOS value) => this.Value = (object) value;\n}\n```\n\nThe code above fails to compile with the following, demonstrating how the `[Union]`\n\nattribute drives the implicit conversions and `switch`\n\nexpressions:\n\n```\nerror CS0029: Cannot implicitly convert type 'MacOS' to 'SupportedOS'\nerror CS8121: An expression of type 'SupportedOS' cannot be handled by a pattern of type 'Windows'.\nerror CS8121: An expression of type 'SupportedOS' cannot be handled by a pattern of type 'Linux'.\nerror CS8121: An expression of type 'SupportedOS' cannot be handled by a pattern of type 'MacOS'.\n```\n\nIf you re-instate the `[Union]`\n\nattribute, everything compiles and runs just fine, which shows how you can create your own *custom* union types.\n\n[Avoiding boxing with custom Union implementations](#avoiding-boxing-with-custom-union-implementations)\n\nGiven we're *just* getting support for `union`\n\ntypes, why might you want to create *custom* `Union`\n\ntypes? One reason is that you might *already* be using custom union types, such as provided by [OneOf](https://www.nuget.org/packages/OneOf), or [Sasa](https://www.nuget.org/packages/Sasa) (two packages I've used in the past). In these cases, the libraries could benefit from built-in language support (e.g. `switch`\n\nexpression support) by simply implementing the `IUnion`\n\ninterface and adding the `[Union]`\n\nattribute.\n\nAnother case is when the \"store the case type in an `object`\n\ninstance\" just isn't good enough for you. The generated union type is *always* a `struct`\n\nwith a single `object`\n\nfield. That means that if you're creating a `union`\n\nof multiple `struct`\n\ntypes, those types are going to be boxed onto the heap.\n\nFor example, imagine you need this `union`\n\n, which can represent either an `int`\n\nor a `bool`\n\n:\n\n```\npublic union IntOrBool(int, bool);\n```\n\nThe problem is that the `int`\n\nor `bool`\n\npassed into the constructor of `IntOrBool`\n\nis immediately boxed to an `object`\n\nand stored in the `Value`\n\nproperty:\n\n```\n[Union]\npublic struct IntOrBool : IUnion\n{\n    public object? Value { get; }\n\n    // The struct arguments are always boxed, allocating on the heap\n    public IntOrBool(int value) => this.Value = (object) value;\n    public IntOrBool(bool value) => this.Value = (object) value;\n}\n```\n\nThis allocates on the heap, which is generally undesirable, as `union`\n\ntypes are intended to be largely transparent performance-wise. Any `switch`\n\nexpressions using this implementation will similarly use the `Value`\n\nproperty. For example, with the basic built-in `union`\n\nimplementation, the following expression:\n\n``` js\nIntOrBool intOrBool;\nvar description = intOrBool switch\n{\n    int i => \"integer\",\n    bool b => \"bool\",\n};\n```\n\nwould lower to code similar to this:\n\n```\nIntOrBool unmatchedValue = new IntOrBool(23);\nobject obj = unmatchedValue.Value; // 👈 Access the boxed value\nstring str;\nif (obj is int _)\n{\n    str = \"integer\";\n}\nelse if (obj is bool _)\n{\n    str = \"bool\";\n}\nelse\n{\n    ThrowSwitchExpressionException((object) unmatchedValue); // can't happen, but handled anyway\n}\n```\n\nIn many cases, the boxing allocation won't really matter, but in other places, such as in hot paths, the boxing is undesirable. To account for this, the `union`\n\nfeature allows for a \"non-boxing\" implementation, using a `TryGetValue`\n\npattern. This requires that you implement:\n\n`bool HasValue { get; }`\n\nwhich returns`true`\n\nif the stored value is non-`null`\n\n`bool TryGetValue(out T value)`\n\nfor each case type,`T`\n\nFor example, the following is a [potential implementation](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/unions#examples-of-union-types) of the `IntOrBool`\n\ntype above that avoids boxing\n\n```\n[Union]\npublic struct IntOrBool : IUnion\n{\n    private readonly bool _isBool;\n    private readonly int _value;\n\n    public IntOrBool(int value)\n    {\n        _isBool = false;\n        _value = value;\n    }\n\n    public IntOrBool(bool value)\n    {\n        _isBool = true;\n        _value = value ? 1 : 0;\n    }\n\n    public bool HasValue => true; // the values are never null\n    public bool TryGetValue(out int value) // get the int value without boxing\n    {\n        value = _value;\n        return !_isBool;\n    }\n    public bool TryGetValue(out bool value) // get the bool value without boxing\n    {\n        value = _isBool && _value is 1;\n        return _isBool;\n    }\n    \n    // 👇 Have to implement this to satisfy IUnion,\n    // and it still boxes, but it won't be used by default.\n    public object Value => _isBool ? _value is 1 : _value;\n}\n```\n\nWhen you implement the `TryGetValue()`\n\nmethods, the compiler automatically uses them in `switch`\n\nexpressions instead of the `Value`\n\nproperty, so the switch expression above becomes the following:\n\n```\nIntOrBool unmatchedValue = new IntOrBool(23);\nstring str;\n// 👇 Calls TryGetValue instead of using the boxing Value property\nif (unmatchedValue.TryGetValue(out int _)) \n{\n    str = \"integer\";\n}\nelse if (unmatchedValue.TryGetValue(out bool _))\n{\n    str = \"bool\";\n}\nelse\n{\n    ThrowSwitchExpressionException((object) unmatchedValue); // can't happen, but handled anyway\n}\n```\n\nDepending on your code paths and use-cases, it may or may not be worth creating custom non-boxing implementations like this, it depends on what you're using the `union`\n\ntypes for in your code base.\n\n[What other features are yet to come?](#what-other-features-are-yet-to-come-)\n\nThe `union`\n\nimplementation is usable as currently shipped, but there's even more to [the language proposal](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/unions) than I've covered. Here are some of the related features that are yet to come:\n\n[Union member providers](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/unions#union-member-providers). These provide a way to define the members that are part of the union type on a*different*type to the union itself.[Closed enums](https://github.com/dotnet/csharplang/blob/main/proposals/closed-enums.md). These are`enum`\n\ns in which you*don't*need to include a \"catch-all\" expression (`_ =>`\n\n) in the`switch`\n\nexpression for the`enum`\n\n.[Closed hierarchies](https://github.com/dotnet/csharplang/blob/main/proposals/closed-hierarchies.md). This allows adding the`closed`\n\nmodifier on a`class`\n\nto prevent derived classes from being declared*outside*the defining assembly, which then similarly allows exhaustive`switch`\n\nexpressions without a catch-all expression.\n\nThese features may or may not make it into .NET 11, but I'll be sure to cover them if they do!\n\n[Summary](#summary)\n\nIn this post I described the support for union types introduced in .NET 11 preview 2. I described the steps you need to implement them, as well as how to deconstruct union types using `switch`\n\nexpressions. I showed the `union`\n\ndeclaration syntax, how they're implemented behind the scenes, as well as how to implement a non-boxing version of a union type. Finally I discussed some of the plans and roadmap for union types and for exhaustiveness improvements in C# that are yet to be released.", "url": "https://wpnews.pro/news/net-ok-c-gets-union-types", "canonical_source": "https://andrewlock.net/exploring-the-dotnet-11-preview-2-dotnet-gets-union-types/", "published_at": "2026-05-22 12:28:50+00:00", "updated_at": "2026-05-23 22:03:45.284449+00:00", "lang": "en", "topics": ["developer-tools"], "entities": [".NET", "C#", "F#", "TypeScript", "Rust"], "alternates": {"html": "https://wpnews.pro/news/net-ok-c-gets-union-types", "markdown": "https://wpnews.pro/news/net-ok-c-gets-union-types.md", "text": "https://wpnews.pro/news/net-ok-c-gets-union-types.txt", "jsonld": "https://wpnews.pro/news/net-ok-c-gets-union-types.jsonld"}}