Designing for union types before C# supports them

5 minute read

Most advice about future-proofing code boils down to the same thing: don’t.

YAGNI. Avoid speculative generality. Don’t introduce abstractions for requirements you don’t have.

Most of the time, that’s good advice.

A small caveat about YAGNI: Residuality Theory actually pushes against a strict interpretation of YAGNI: in complex systems, designing only for the requirements you already know can leave you poorly prepared for the stressors you don’t know yet. That deserves a post of its own.

But there’s a difference between guessing what you might need someday and looking at a language feature that’s already taking shape.

Union types are coming to C#.

I don’t want to depend on them yet.

That doesn’t mean I have to pretend I don’t know they’re coming.

What I would normally write today

Imagine an API where a result can have one of a few different forms.

For example:

public sealed record Success(string Message);

public sealed record Failure(string Error);

If I need a common result abstraction today, my first instinct would probably be a marker interface:

public interface IResult
{
}

Then the two result types can implement it:

public sealed record Success(string Message) : IResult;

public sealed record Failure(string Error) : IResult;

It’s simple.

It’s idiomatic.

And if today’s requirements were the only thing I knew about, I’d probably stop there.

What I actually want to express

There’s a subtle problem with that model, though.

Success and Failure aren’t really two different implementations of a result.

A Result is a value that can contain either a Success or a Failure.

That’s a slightly different relationship.

The marker interface says:

Success is a Result
Failure is a Result

What I really mean is:

Result contains a Success or a Failure

Today, expressing that cleanly requires a little more work.

Tomorrow, C# union types will express exactly that.

And that changes how I want to design the code today.

Writing tomorrow’s shape today

Instead of making the case types aware of Result, I can keep them completely independent:

public sealed record Success(string Message);

public sealed record Failure(string Error);

Then I can model the union itself:

public sealed class Result
{
    public Result(Success value) => Value = value;

    public Result(Failure value) => Value = value;

    public object? Value { get; }
}

I’ll admit it: compared with the marker interface, this is uglier.

I’ve introduced a wrapper.

I’ve written two nearly identical constructors.

And Value has the wonderfully unspecific type object?.

If you looked only at what C# offers today, it would be reasonable to ask whether all of that ceremony is actually worth it.

But look at the model rather than the boilerplate.

Success knows nothing about Result.

Failure knows nothing about Result.

Result knows exactly which types it can contain.

And every Result instance contains one of those values.

The relationship is already the one I actually wanted to express.

Then union types arrive

With native union types, the same model becomes:

public sealed record Success(string Message);

public sealed record Failure(string Error);

public union Result(Success, Failure);

That’s it.

The interesting part isn’t just that the syntax is shorter.

The compiler-generated union has essentially the same fundamental shape as the type we wrote manually:

[Union]
public struct Result : IUnion
{
    public Result(Success value) => Value = value;

    public Result(Failure value) => Value = value;

    public object? Value { get; }
}

The constructors describe the allowed case types.

Value contains the current case.

The big difference is that the compiler now understands what the type means.

And that unlocks the part I can’t reproduce cleanly today.

From boilerplate to language semantics

Once Result is a real union, I can assign one of its cases directly:

Result result = new Success("Done");

And I can pattern match over the possible values:

var message = result switch
{
    Success success => success.Message,
    Failure failure => failure.Error
};

Because the compiler knows the complete set of cases, it can reason about exhaustiveness too.

That’s much more important than saving a couple of constructors.

Today I’m manually expressing the shape of the model.

Tomorrow the language understands its semantics.

And migrating between the two is pleasantly uneventful.

The case types don’t need to be redesigned.

Their relationship doesn’t need to be inverted.

I don’t need to introduce another abstraction between consumers and the new language feature.

I replace this:

public sealed class Result
{
    public Result(Success value) => Value = value;

    public Result(Failure value) => Value = value;

    public object? Value { get; }
}

with this:

public union Result(Success, Failure);

The ugly duckling becomes a swan.

I’m not implementing union types myself

There’s an important distinction here.

The class I wrote today is not a home-grown union type.

I’m not trying to reproduce exhaustive pattern matching.

I’m not introducing discriminators, reflection, source generators, or a generic OneOf<T1, T2, ...> framework.

And I’m not taking a dependency on preview language functionality.

I’m just choosing a representation that already has the shape of the language feature I expect to use later.

That’s a much smaller bet.

In fact, there’s a useful test hidden in there.

If preparing for a future language feature requires building a significant amount of machinery to imitate that feature, I’m probably doing too much.

If it means choosing between two perfectly workable models today, and one of them can later collapse into native syntax, that’s a different proposition.

What if union types change?

Upcoming language features aren’t contracts.

Designs evolve. Syntax changes. Features get postponed.

So code written with a future feature in mind still needs to make sense without that feature.

My Result class passes that test.

It models a legitimate concept today: a result containing one of a known set of values.

The constructors enforce which values can be used.

The Value property exposes the contained value.

Nothing requires native union support for that model to work.

If C# union types disappeared tomorrow, I’d still have valid code.

I’d just be left with some boilerplate I had hoped the compiler would eventually take over.

That’s a risk I’m comfortable with.

Recap

Upcoming language features don’t need to be production-ready before they become useful.

In this case, knowing how C# union types are taking shape changes a design decision I can make today.

Instead of modeling Success and Failure as implementations of a common interface, I can model Result as the thing that contains one of those values.

Today, that costs me a small amount of boilerplate:

public sealed class Result
{
    public Result(Success value) => Value = value;

    public Result(Failure value) => Value = value;

    public object? Value { get; }
}

Later, the language can replace that boilerplate directly:

public union Result(Success, Failure);

The goal isn’t to implement tomorrow’s feature today.

It’s to give today’s code tomorrow’s shape, so that when the language catches up, I can replace boilerplate with syntax.

Support this blog

If you liked this article, consider supporting this blog by buying me a pizza!