Look at a type long enough and you start to notice how many states it can represent that should never happen.

An order that’s Pending but has a PaidAt timestamp. An order that’s Refunded with no refund reason. A RefundedAt that’s set while PaidAt is still null — refunded before it was ever paid.

None of these are supposed to exist. And yet the type happily lets you build every one of them. So the codebase fills up with guard clauses that check for states the domain says can’t occur — and every one of those checks is a place where someone can forget, or get it wrong.

Make illegal states unrepresentable is the fix: design the type so the bad combinations can’t be constructed in the first place. Last week I pointed Claude Code at exactly this kind of type, and the interesting part wasn’t the principle — it was how much of the work turned out to be mechanical.


The setup

Here’s the model. A status enum, plus the fields that only make sense in some of those statuses:

public class Order
{
    public Guid Id { get; set; }
    public OrderStatus Status { get; set; }

    public DateTimeOffset? PaidAt { get; set; }
    public DateTimeOffset? RefundedAt { get; set; }
    public string? RefundReason { get; set; }
}

public enum OrderStatus { Pending, Paid, Refunded }

It reads fine. Every real order fits in it. The problem is everything else that also fits in it.


Let Claude Code count the states

I gave it the type and one question:

“How many distinct states can this Order type represent, and how many of those are actually legal in the domain? A pending order has no payment; a paid order has a PaidAt; a refunded order has a PaidAt, a RefundedAt, and a reason.”

The answer was the point of the whole exercise. Three enum values, multiplied by the presence-or-absence of three nullable fields, gives 3 × 2 × 2 × 2 = 24 representable states. Of those, exactly three are legal:

  • Pending with all three fields null
  • Paid with PaidAt set, the rest null
  • Refunded with all three set

The other twenty-one are combinations the compiler will let you build and that the domain forbids. Every guard clause in the codebase exists to defend against one of those twenty-one. That’s not a modeling detail — that’s the source of a whole category of bugs.


Making the illegal states unrepresentable

The fix is to stop modeling “a status and some maybe-fields” and start modeling “one of three specific shapes.” In C# that’s a closed hierarchy of records:

public abstract record Order
{
    public required Guid Id { get; init; }

    public sealed record Pending : Order;

    public sealed record Paid(DateTimeOffset PaidAt) : Order;

    public sealed record Refunded(
        DateTimeOffset PaidAt,
        DateTimeOffset RefundedAt,
        string Reason) : Order;

    // Private constructor: only the nested records above can derive
    // from Order, so these three are the only possible shapes.
    private Order() { }
}

Now the illegal states are gone — not caught, gone. There is no way to construct a Refunded order without a reason, because Reason is a constructor parameter. There is no Pending order carrying a stray PaidAt, because Pending has no such field. The twenty-one nonsense states can’t be typed.

The private constructor seals the hierarchy: because the records are nested inside Order, they can call its private constructor, but nothing outside can. So Pending, Paid, and Refunded are the only orders that can ever exist.


What the compiler now does for you

Consuming code switches on the shape instead of interrogating fields:

var label = order switch
{
    Order.Pending      => "Awaiting payment",
    Order.Paid p       => $"Paid on {p.PaidAt:d}",
    Order.Refunded r   => $"Refunded: {r.Reason}",
};

No null checks. No if (order.Status == Refunded && order.RefundReason is null) defensive branch — that state can’t reach this code.

A switch like this is also pure decision logic: no database, no clock, nothing to mock. That makes it exactly the kind of code that belongs in a functional core, with the effects pushed out to the shell.

One honest caveat about C#: unlike an enum — or a discriminated union in F# or Rust — the compiler doesn’t treat a type hierarchy as closed. It can’t see that Pending, Paid, and Refunded are the only shapes, so this switch actually compiles with a CS8509 warning (“not exhaustive”) unless you add a default arm. That’s less of a problem than it sounds: leave the default arm out on purpose and the warning becomes a standing nudge to revisit every switch when the model grows. If you’d rather add a _ => throw new ... arm, back it with a test that every shape is handled, because the compiler no longer will. Either way the real guarantee lives in the type, not the switch — the twenty-one illegal states never reach this code to begin with.


The functional programming connection

If you’ve worked in F#, Rust, or any language with algebraic data types, this pattern already looks familiar. A value is exactly one of a fixed set of shapes, and each shape carries only the data that belongs to it. That’s what the record hierarchy bought us: Pending, Paid, and Refunded stopped being a status with a bag of optional fields and became three distinct variants of one concept.

C# doesn’t have native discriminated unions today, so the closed hierarchy is an approximation. The private base constructor gives you the guarantee that matters — only the legal shapes can be built — but the compiler still won’t prove your matches are complete, which is why that CS8509 warning showed up earlier.

That gap is closing. The C# language design team has a championed proposal for union types, and Microsoft is already publishing a preview feature specification for it. The core pieces — union types, closed enums, and closed hierarchies — have been approved in principle at the design meetings, so this is a matter of when, not if. There’s no committed version yet, but a future C# will let you write the whole thing as one line:

public union Order(Pending, Paid, Refunded);

With a real union the compiler knows the set is closed, so the switch becomes provably exhaustive — the CS8509 warning disappears, and adding a variant turns every unhandled switch into an error automatically. Exactly the F#/Rust behaviour, built into the language.

Until that ships, you can get most of the way there today. Rather than promoting every warning to an error, promote just that one diagnostic:

<PropertyGroup>
  <WarningsAsErrors>$(WarningsAsErrors);CS8509</WarningsAsErrors>
</PropertyGroup>

Now a non-exhaustive switch fails the build. Add a fourth variant — say Cancelled — and every switch that doesn’t handle it stops compiling until I fix it. It’s a warning policy rather than a true closed union, but in practice it’s the same net.

And it’s exactly the kind of change I hand to Claude Code. After adding a variant I ask it to “find every switch on the Order hierarchy and add the missing arm.” The compiler tells me which ones are broken; Claude Code fixes them in one pass. I model the domain as a set of explicit alternatives — the machine keeps every match honest.


The other lever: primitive obsession

The same principle applies one level down, to the fields themselves. string Reason still lets you pass "". decimal Amount still lets you pass a negative number. Wrapping them in small value objects with a validating factory pushes those illegal values out too:

public readonly record struct RefundReason
{
    public string Value { get; }

    private RefundReason(string value) => Value = value;

    public static Result<RefundReason> Create(string value) =>
        string.IsNullOrWhiteSpace(value)
            ? Result.Failure<RefundReason>("Refund reason is required.")
            : Result.Success(new RefundReason(value.Trim()));
}

The Result<T> here is just a success-or-failure wrapper (from CSharpFunctionalExtensions, or your own). Now an empty reason can’t slip past Create — though because this is a struct, default(RefundReason) still bypasses the factory with a null Value; make it a sealed record if you want to close that gap too. This is the same idea as parse, don’t validate: validate once at the edge, then carry a type that proves the value is good, instead of re-checking it everywhere.


What Claude Code is genuinely good at here — and what it isn’t

The redesign itself is a judgment call, and that part stayed with me. Deciding that there are exactly three legal shapes, that a refund always has a reason, that you can’t refund before paying — that’s domain knowledge. Claude Code proposed a model, but I’m the one who knows the business rules, so I’m the one who signs off.

Everything after that decision was mechanical, and that’s where the AI earned its keep:

  • Counting the states. Enumerating 24 representable states and marking the 21 illegal ones is tedious and error-prone by hand. Claude Code did it in one pass and gave me a table to sanity-check.
  • Rewriting the consumers. Every place that did if (order.Status == OrderStatus.Paid) had to become a switch arm. In a real codebase that’s dozens of call sites. I asked Claude Code to “convert every consumer of Order.Status to a switch expression on the new record hierarchy, and list any branch that no longer type-checks.”
  • Finding the now-dead guards. The whole point was to delete defensive code. Claude Code is good at spotting if (x is null) throw checks that guard against a state the new type can’t produce, and flagging them for removal.

The pattern is the same one I keep coming back to, and the reason my job now looks more like architecture than typing: I make the design decision, Claude Code does the fan-out.


Count the illegal states

Find a class in your codebase with a status enum and two or more nullable fields that only apply to some of those statuses. It’s a common shape — orders, subscriptions, documents, anything with a lifecycle.

Then, before you refactor anything, point Claude Code at it and ask it to count:

“How many distinct states can this type represent, and how many are actually legal in the domain? List the illegal combinations.”

Read the illegal ones. If the list is longer than the legal ones — and it usually is — you’ve found a type that’s been asking your whole team to be careful, when it could have been making the mistakes impossible instead.