C# Gets Discriminated Unions in .NET 11

by DeeDee Walsh, on Apr 18, 2026, 2:25:06 PM

Originally published April 18, 2026. Updated August 8, 2026 for .NET 11 Preview 6 — corrected syntax, added serialization and performance detail, and revised the availability guidance. 

Why This Matters If You're Still Writing VB6 Variants

If you've maintained a VB6 codebase for any length of time, you know the pattern. A function that sometimes returns a String, sometimes a Long, sometimes Nothing, and occasionally an error value, all wrapped in the comfortable ambiguity of the Variant type. It works. It's worked for 25 years. And every developer who's tried to translate that code into modern C# has hit the same wall: C# had no clean way to say "this returns one of several different things."

C# 15 closes that gap with the union keyword, the feature commonly called discriminated unions, or sum types, that F# developers have had for two decades. For teams modernizing VB6, PowerBuilder, or Clarion applications, it removes one of the last meaningful expressiveness gaps between legacy Basic-family languages and idiomatic C#.

The feature has moved considerably since we first wrote about it in April, and one of those changes affects the code you write. 

The Pattern That Has Always Been Awkward to Translate

Here's a pattern we see constantly in VB6 modernization work. A data-access function returns a Variant that could be the requested record, Empty if nothing was found, or an error string if something went wrong:

 ' VB6 — classic Variant return pattern
Public Function LookupCustomer(ByVal customerID As Long) As Variant
    Dim rs As ADODB.Recordset

    On Error GoTo ErrorHandler

    Set rs = New ADODB.Recordset
    rs.Open "SELECT * FROM Customers WHERE ID = " & customerID, _
            gConnection, adOpenStatic, adLockReadOnly

    If rs.EOF Then
        LookupCustomer = Empty
    Else
        LookupCustomer = Array(rs!CustomerName, rs!CreditLimit, rs!Status)
    End If

    rs.Close
    Exit Function

ErrorHandler:
    LookupCustomer = "ERROR: " & Err.Description
End Function


The caller then interrogates the return value with IsEmpty, IsArray, or VarType to work out what actually came back. It isn't elegant, but it is expressive. The function genuinely returns one of three shapes, and VB6's type system lets you say that without ceremony.

Translating this to pre-union C# always required a compromise, and all the options are worse than the original in some way:

Throw an exception for "not found." Semantically wrong. A customer that doesn't exist is an expected outcome, not an exceptional one and exceptions are expensive on a hot lookup path.

Return a nullable tuple plus a separate error string. Multiple out-parameters, or a wrapper class with three properties where only one is meaningful at a time. Callers have to remember which field to check, and nothing stops them checking the wrong one.

Build a custom result class with a discriminator enum. Closest to correct, but verbose. You write the boilerplate, you maintain it, and the compiler still can't tell you when you've forgotten a case.

Take a dependency on OneOf or a similar package. Workable, and many teams do it. But it's a third-party dependency in your core domain model, and exhaustiveness is enforced at runtime rather than by the compiler.

None of these say what the original code means: this function returns exactly one of three things, and you must handle all three.

What Unions Let You Write Instead

Here's the part worth reading carefully if you saw the April version of this post, because C# unions don't work the way most people first assume.

C# unions compose existing standalone types. You declare your case types normally, then declare a union over them. Cases are not written inline inside the union body. That's the F# model and C# deliberately went a different way so that unions compose with types you already have.

 // Case types are ordinary types you declare yourself
public record class Customer(string Name, decimal CreditLimit, CustomerStatus Status);
public record class NotFound();
public record class LookupError(string Message);

// The union composes them into a closed set
public union CustomerLookupResult(Customer, NotFound, LookupError);


The method returns any of the case types directly. An implicit conversion lifts it into the union: 

 public CustomerLookupResult LookupCustomer(long customerId)
{
    try
    {
        using var connection = new SqlConnection(_connectionString);
        connection.Open();

        using var command = new SqlCommand(
            "SELECT Name, CreditLimit, Status FROM Customers WHERE Id = @id",
            connection);
        command.Parameters.AddWithValue("@id", customerId);

        using var reader = command.ExecuteReader();
        if (!reader.Read())
        {
            return new NotFound();
        }

        return new Customer(
            reader.GetString(0),
            reader.GetDecimal(1),
            Enum.Parse<CustomerStatus>(reader.GetString(2)));
    }
    catch (SqlException ex)
    {
        return new LookupError(ex.Message);
    }
}


And the caller gets compiler-enforced handling of every case, matching on the case types themselves: 

 var result = LookupCustomer(12345);

var message = result switch
{
    Customer c    => $"Found {c.Name}, credit limit {c.CreditLimit:C}",
    NotFound      => "No customer with that ID",
    LookupError e => $"Lookup failed: {e.Message}"
};


Note what isn't there: no default arm. The compiler knows the set is closed and that you've covered it. Add a fourth case, say, Suspended and every incomplete switch in the codebase surfaces at build time rather than as a production surprise.

That is the property VB6's Variant never had. The runtime told you what came back. The compiler never did.

What changed since April

Unions have moved through several previews, and the sequence matters if you're reading older write-ups, including the April version of this post, whose code samples used a syntax that was never the shipped design.

The syntax composes types; it doesn't declare them inline. If you have samples that declare cases inside the union body, they won't compile. Declare the case types first, then the union over them.

The support types now ship in the box. Previews 2 through 5 required you to hand-author UnionAttribute and IUnion yourself. Preview 6 ships them as System.Runtime.CompilerServices.UnionAttribute and System.Runtime.CompilerServices.IUnion, so a union declaration compiles with no extra boilerplate.

System.Text.Json serializes unions. The serializer writes the active case directly, through a new JsonTypeInfoKind.Union contract kind, in both the reflection-based serializer and the source generator. This is the change that makes unions viable in the service layer of a modernized app rather than only in internal domain code.

One caveat that matters for API design: writing the active case directly means the default wire format carries no discriminator. A union holding a Customer serializes as a plain customer object. If your consumers need to tell the cases apart, JsonUnionAttribute, JsonUnionCaseInfo, and the type-classifier APIs let you control how cases are discovered and named. Decide that deliberately rather than discovering it in integration testing.

Know the boxing behavior before you put unions on a hot path. A union lowers to a record struct holding a single object? value, which means value-type cases get boxed. For domain modelling and service boundaries this is a non-issue. For a tight loop in a migrated calculation engine, the kind of code that shows up constantly in Clarion and PowerBuilder business logic, it's worth measuring. The specification allows a non-boxing escape hatch for performance-critical paths.

Why This Matters for Modernization Projects

At GAPVelocity AI, our agentic modernization pipeline has always faced a choice when translating Variant-returning code: pick one of the compromise patterns above, or take a dependency on a library like OneOf to simulate unions. Neither produces code that looks the way a C# developer writing greenfield code in 2026 would write it. Unions change that. The Architect and Translation agents can emit target code that is both faithful to the original semantics and idiomatic for the modern platform.

The same applies to PowerBuilder modernization, where the Any datatype fills a similar role to VB6's Variant, and to Clarion applications where loosely-typed return values are the norm in older business logic. Any legacy language that leaned on dynamic or variant-like types for expressiveness produces code that is genuinely easier to translate now than it was six months ago.

There's a broader pattern worth naming. Every release of .NET widens the expressiveness gap between modern C# and the legacy languages still running production workloads. Records arrived in C# 9. Pattern matching kept getting stronger through C# 10 and 11. Required members and primary constructors landed in C# 12. Now unions. Each one makes the target of a modernization project more attractive — and makes "keep it on VB6 for another year" a little more expensive in opportunity cost.

The Practical Takeaway — and an Honest Note on Timing

If you're evaluating modernization for a VB6, PowerBuilder, or Clarion codebase, the quality of the output matters as much as the speed of getting there. Output that uses unions where the original used Variants will be easier for your team to read, maintain, and extend than output that papers over the pattern with nullable wrappers and error flags. It'll also be easier for static analysis, security scanners, and AI-assisted refactoring tools to reason about, because the compiler finally has enough information to understand what the code's trying to do.

Unions are still a C# language preview feature. Trying them today requires a .NET 11 preview SDK, net11.0 as the target framework, and <LangVersion>preview</LangVersion> in the project file. .NET 11 is expected to reach general availability in November 2026, but the feature is still evolving, and "in preview at Preview 6" is not the same as "guaranteed stable at GA." 

So: plan for unions, prototype with them, and factor them into target-platform decisions for work landing in 2027. Don't put preview-gated language features into a production migration you're delivering this quarter. VELO currently targets .NET 10 for production migrations, and that remains the right call for the large majority of teams.

FAQ

Are C# unions the same as F# discriminated unions? Not structurally. C# unions compose existing standalone types into a closed set, rather than declaring case tags inside the union itself. The practical result: a value that is exactly one of a fixed set of types, with compiler-enforced exhaustive pattern matching is equivalent.

Which .NET 11 preview introduced unions? The union keyword arrived in Preview 2. The compiler support types shipped in the framework in Preview 6, removing the hand-authored boilerplate earlier previews required.

Can I use unions in production today? No. They're a preview language feature requiring a preview SDK and LangVersion set to preview. Microsoft marks preview releases as unsupported for production use.

Do unions replace the OneOf package? For the core use case, yes, with one meaningful advantage: exhaustiveness is enforced by the compiler at build time rather than at runtime, and you drop a third-party dependency from your domain model.

Do unions serialize to JSON? Yes, as of Preview 6. The serializer writes the active case directly, so the default output carries no discriminator. Use JsonUnionAttribute and the type-classifier APIs if your consumers need to distinguish cases on the wire.

What replaces a VB6 Variant if unions aren't available yet? A result class with a discriminator enum is the closest pre-union equivalent, and it's what most migration output uses today. It costs boilerplate and gives up compile-time exhaustiveness, which is the gap unions close.

Modernizing a VB6, PowerBuilder, Clarion, Delphi, WebForms, Winforms or Access codebase? Talk to an engineer about what your target code should actually look like.

Topics:.NETC#.NET 11

Comments

Subscribe to GAPVelocity AI Modernization Blog

FREE CODE ASSESSMENT TOOL