C# Gets Discriminated Unions in .NET 11

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

Why This Matters If You're Still Writing VB6 Variants

If you've been maintaining a VB6 codebase for any length of time, you know the pattern well. 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 has worked for 25 years. And every developer who has ever tried to translate that code into modern C# has run into the same wall: C# didn't have a clean way to say "this returns one of several different things."

That just changed. In .NET 11 Preview 3, Microsoft added union type support to C#. This is the feature commonly called discriminated unions, or sum types, that F# developers have enjoyed for two decades. For teams modernizing VB6 or PowerBuilder applications, this is a great new feature. It closes one of the last meaningful expressiveness gaps between legacy Basic-family languages and idiomatic C#.

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 has to interrogate the return value with IsEmpty, IsArray, or VarType to figure out what actually came back. It's not elegant, but it's expressive. The function genuinely returns one of three different shapes, and VB6's type system lets you say that without ceremony.

Translating this to pre-union C# has always required a compromise. The usual options are all worse than the original in some way:

Option 1: Throw exceptions for "not found." Semantically wrong. "Customer doesn't exist" is not exceptional; it's an expected outcome.

Option 2: Return a nullable tuple and 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.

Option 3: Build a custom result class with a discriminator enum. The closest to right, but verbose. You write the boilerplate, you maintain it, and the compiler can't help you if you forget to handle a case.

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

What Unions Let You Write Instead

With .NET 11's union support, the C# equivalent can finally express the same intent directly:

 // C# with .NET 11 union support
public union CustomerLookupResult
{
    Customer(string Name, decimal CreditLimit, CustomerStatus Status);
    NotFound;
    Error(string Message);
}

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 CustomerLookupResult.NotFound();
        }

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

 

 The caller now gets compiler-enforced handling of every case: 

 var result = LookupCustomer(12345);

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


If a future developer adds a fourth case to CustomerLookupResult, say, a Suspended variant, every switch expression that handles the type will generate a compiler warning until it's updated. That's the property VB6's Variant never had: exhaustiveness. The runtime told you what came back; the compiler never did.

Why This Matters for Modernization Projects

At GAPVelocity AI, our agentic modernization pipeline has always had to make a choice when translating Variant-returning code: pick one of the compromise patterns above, or introduce a third-party library like OneOf to simulate unions. Neither option 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 now 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 serves 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 here 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 more powerful through C# 10 and 11. Required members and primitive constructors showed up in C# 12. Now unions. Each one of these features makes the target of a modernization project more attractive. And each one makes the "just keep it on VB6 for another year" decision a little more expensive in opportunity cost.

The Practical Takeaway

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 will also be easier for static analysis, security scanners, and future AI-assisted refactoring tools to reason about, because the compiler finally has enough information to understand what the code is trying to do.

Unions are in preview in .NET 11 Preview 3, which means they'll be generally available when .NET 11 ships later this year. For modernization projects currently in planning, it's worth factoring the timing into your target platform decision. And for projects already in flight on .NET 9 or .NET 10, it's worth knowing that a meaningful improvement to the target language is landing before your rollout is complete.

The modernization target keeps getting better. The cost of staying on the legacy platform keeps getting higher. Unions are one more data point in a trend that has been running for a decade and for teams sitting on millions of lines of Variant-returning code, it's a welcome one.

Topics:.NETC#.NET 11

Comments

Subscribe to GAPVelocity AI Modernization Blog

FREE CODE ASSESSMENT TOOL