Blog

Make Illegal States Unrepresentable

How to design types so impossible data can’t be built: sum types, exhaustive matching, newtypes, parsing at the boundary and typestate, in C#, Java, Go and Rust, with what each compiler actually says when a case is missed.

Most bugs in business code aren’t wrong algorithms. They’re data in a shape nobody planned for: an order marked shipped that was never paid, a customer ID that’s an empty string, a distance in feet passed where metres were expected. Every such bug has two halves. Something built the bad value, and something later trusted it.

This part is about removing the first half. Instead of checking for impossible data everywhere it’s used, you design types in which the impossible data can’t be written down. Yaron Minsky of Jane Street put it as a slide heading, “Make illegal states unrepresentable”. We’ll see how far each of our four languages lets you take that, and we’ll show what each compiler really says when you get it wrong, because the answers differ more than most comparisons admit.

Try this first

An order record like this one appears in many codebases:

class Order
{
    public bool IsPaid;
    public bool IsShipped;
    public bool IsCancelled;
    public long? AmountCents;     // set once paid
    public string? Tracking;      // set once shipped
    public string? CancelReason;  // set once cancelled
}

The business allows four states: placed, paid, shipped, and cancelled before payment. Counting each optional field only as present or absent, how many different combinations can this record hold? How many of them are legal? Write both numbers down.

Why flags and nulls let bad states in

The record has six fields, and each has two cases that matter: true or false, present or absent. That’s 2 × 2 × 2 × 2 × 2 × 2 = 64 combinations. Four are legal. The other 60 are states the business says can’t happen, and the type happily holds every one of them.

every value the order record can hold Pl Ca Pd Sh breaks "an amount exactly when paid": 32 breaks "a tracking number exactly when shipped": 16 breaks "a reason exactly when cancelled": 8 breaks "shipped only after paid": 2 breaks "cancelled only before payment": 2 Placedno fields Paidamount Shippedamount, tracking Cancelledreason each case carries only the fields that state has, so no combination is left to reject

Counts from checks/part09_states.py, which enumerates every combination and records the first rule each one breaks. Each red square breaks at least that rule; many break several.

The figure’s lab enumerates all 64 and records the first rule each illegal one breaks:

First rule broken Combinations
an amount exactly when paid 32
a tracking number exactly when shipped 16
a reason exactly when cancelled 8
shipped only after paid 2
cancelled only before payment 2

Each of those rules becomes an if somewhere: in the constructor, in every method that changes the order, in the code that loads it from the database, in the code that reads it from a message queue. Miss one, and a state that “can’t happen” happens. The type gives the reader no help either: nothing on Tracking says it’s only set when IsShipped is true.

Null is the same problem in its smallest form. Every reference in C# (without nullable annotations) and Java can hold either a value or null, so every use hides a check. Tony Hoare, who added the null reference to ALGOL W, called it his “billion-dollar mistake” in the abstract of his QCon London talk in 2009: “It was the invention of the null reference in 1965.”

The fix is to change the type, not to add more checks. Model the order as one of four cases, where each case carries only the data that state has. A placed order has no amount field to leave empty. A shipped order always has a tracking number field, because the case can’t be built without one. That gives 4 representable states, all 4 legal, and no illegal combinations left for code to reject. What goes inside each field, such as an empty or null tracking number, is a separate problem, and the newtypes section below deals with it.

Sum types: one of several cases

A type that is “exactly one of these cases, each with its own data” is a sum type, also called a tagged union, a variant or an algebraic data type. A record or class with several fields is a product type: it has all of its fields at once. The flags design used a product where the business needed a sum.

Sum types need two things from a language to be worth much:

  1. A closed set of cases. Nobody outside the definition can add a fifth.
  2. Exhaustive matching. When code branches on the cases, the compiler checks that every case is handled.

The four languages give you very different amounts of each.

Rust: enums

A Rust enum is a sum type. Each variant can carry its own fields, and match must cover every variant:

enum Order {
    Placed,
    Paid { amount_cents: u64 },
    Shipped { amount_cents: u64, tracking: String },
    Cancelled { reason: String },
}

fn label(order: &Order) -> String {
    match order {
        Order::Placed => "placed".to_string(),
        Order::Paid { amount_cents } => format!("paid {amount_cents}"),
        Order::Shipped {
            amount_cents,
            tracking,
        } => format!("shipped {tracking}, paid {amount_cents}"),
        Order::Cancelled { reason } => format!("cancelled: {reason}"),
    }
}

fn main() {
    let orders = [
        Order::Placed,
        Order::Paid { amount_cents: 1999 },
        Order::Shipped {
            amount_cents: 1999,
            tracking: "1Z999".to_string(),
        },
        Order::Cancelled {
            reason: "out of stock".to_string(),
        },
    ];
    for order in &orders {
        println!("{}", label(order));
    }
}

It prints:

placed
paid 1999
shipped 1Z999, paid 1999
cancelled: out of stock

Now suppose the business adds refunds. You add a Refunded variant and forget label:

enum Order {
    Placed,
    Paid { amount_cents: u64 },
    Refunded { amount_cents: u64 },
}

fn label(order: &Order) -> String {
    match order {
        Order::Placed => "placed".to_string(),
        Order::Paid { amount_cents } => format!("paid {amount_cents}"),
    }
}

fn main() {
    println!("{}", label(&Order::Refunded { amount_cents: 1999 }));
}

It doesn’t compile:

error[E0004]: non-exhaustive patterns: `&Order::Refunded { .. }` not covered

That error is the point of the whole technique. Adding a case turns every place that must now handle it into a compile error, with a list of where they are. The Rust Book says it plainly: “Matches in Rust are exhaustive: We must exhaust every last possibility in order for the code to be valid.”

One deliberate exception: a library can mark an enum #[non_exhaustive]. Outside its crate, the Reference says, “matching on a variant does not contribute towards the exhaustiveness of the arms”, so callers must add a wildcard arm. That keeps the library free to add variants, and it gives up exactly this check for its users. Use it for public library types that will grow, not for your own domain model.

Java: sealed interfaces and records

Java 17 finalised sealed classes and interfaces (JEP 409): a permits clause names every allowed subtype. Java 21 finalised pattern matching for switch (JEP 441), which uses that list to check exhaustiveness. With records for the cases, the order looks like this (the bare void main() and IO.println need Java 25’s compact source files):

import java.util.List;

sealed interface Order permits Placed, Paid, Shipped, Cancelled {}

record Placed() implements Order {}

record Paid(long amountCents) implements Order {}

record Shipped(long amountCents, String tracking) implements Order {}

record Cancelled(String reason) implements Order {}

String label(Order order) {
    return switch (order) {
        case Placed p -> "placed";
        case Paid p -> "paid " + p.amountCents();
        case Shipped s -> "shipped " + s.tracking();
        case Cancelled c -> "cancelled: " + c.reason();
    };
}

void main() {
    List<Order> orders = List.of(
            new Placed(), new Paid(1999), new Shipped(1999, "1Z999"),
            new Cancelled("out of stock"));
    for (Order order : orders) {
        IO.println(label(order));
    }
}

It prints:

placed
paid 1999
shipped 1Z999
cancelled: out of stock

Add Refunded to the permits list without a new case, and javac refuses: “the switch expression does not cover all possible input values”. That’s an error, not a warning.

There’s a trap, and JEP 441 names it. If the switch has a default branch, adding Refunded compiles silently, and the new case goes wherever default sends it. Our lab’s version printed unknown for a refunded order. The JEP’s words: “A match-all clause risks sweeping exhaustiveness errors under the rug.” Over a sealed type, leave default out.

Two more limits. Exhaustiveness is checked only for switch expressions and for switches that use patterns or case null. An old-style switch statement over an enum still isn’t checked: we added REFUNDED to an enum with such a switch, and it compiled cleanly and fell through to the variable’s initial value. And because classes are compiled separately, a new subtype can appear at run time that the compiler never saw. The JEP says the compiler inserts a synthetic default that throws, which is MatchException since Java 21. Null is the fifth case here: a null order reaching a pattern switch throws NullPointerException, unless the switch has a case null.

C#: records, a closed hierarchy by convention, and C# 15’s closed

C# 14, on .NET 10, has no way to tell the compiler that a class hierarchy is closed. A switch expression over an abstract record base gets warning CS8509, “The switch expression does not handle all possible values of its input type”, even when every subtype is covered: the C# 14 compiler doesn’t reason about which types derive from a class at all. Our lab’s four-case switch got the warning with all four cases handled, and got the same warning, still suggesting the pattern '_', after Refunded was added.

The usual C# 14 pattern closes the hierarchy by hand: nest the cases inside the base record and give it a private constructor, so that code elsewhere can’t derive from it by accident. The compiler still can’t see that it’s closed, so the switch ends with an explicit arm that throws:

using System.Diagnostics;

Order[] orders =
[
    new Order.Placed(),
    new Order.Paid(1999),
    new Order.Shipped(1999, "1Z999"),
    new Order.Cancelled("out of stock"),
];
foreach (var order in orders)
{
    Console.WriteLine(Label(order));
}

static string Label(Order order) => order switch
{
    Order.Placed => "placed",
    Order.Paid p => $"paid {p.AmountCents}",
    Order.Shipped s => $"shipped {s.Tracking}",
    Order.Cancelled c => $"cancelled: {c.Reason}",
    _ => throw new UnreachableException($"unhandled order state {order}"),
};

public abstract record Order
{
    // Private, so other types can't call it. A record also gets a protected copy constructor.
    private Order() { }

    public sealed record Placed : Order;
    public sealed record Paid(long AmountCents) : Order;
    public sealed record Shipped(long AmountCents, string Tracking) : Order;
    public sealed record Cancelled(string Reason) : Order;
}

It prints:

placed
paid 1999
shipped 1Z999
cancelled: out of stock

This doesn’t give you the check. Add Refunded and forget Label, and it compiles without a word, then throws when a refunded order arrives. Without the _ arm, our lab’s version threw SwitchExpressionException instead. Either way, you find out at run time.

And with records, the set isn’t fully closed either. Every non-sealed record gets a compiler-generated protected copy constructor, which a private constructor doesn’t replace, so public record Rogue(Order Inner) : Order(Inner); compiles anywhere. Our lab’s Rogue compiled and reached the _ arm. If the seal itself matters, make the base an abstract class with a private constructor and the cases nested sealed classes: a class has no copy constructor, and the same Rogue failed with error CS0122: 'Order.Order()' is inaccessible due to its protection level. Like Go’s seal below, the record version stops accidents, not someone determined.

Don’t reach for WarningsAsErrors here. In C# 14, CS8509 fires even when every case is handled, so as an error it would break every correct switch. The throwing _ arm is the practical option until closed ships.

C# 15 changes this, and it’s still a preview. The .NET 11 previews add a closed modifier: “A closed class can only be derived from within its declaring assembly, which fixes the set of direct descendants at compile time.” They also add union types, which the language proposal describes as unions of types, not “discriminated” or “tagged” unions. We compiled the same order with public closed record Order; on the .NET 11 preview 7 SDK with LangVersion=preview. With all four cases handled, there was no warning and no _ arm. After adding Refunded:

warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Refunded' is not covered.

It’s still a warning. The build succeeds, and our program threw SwitchExpressionException when a refunded order reached the switch. To make it a real guarantee, turn that warning into an error in the project file, <WarningsAsErrors>CS8509</WarningsAsErrors>, which our lab confirmed fails the build. Don’t ship preview language features in production code; do plan for them, because they remove the C# 14 workaround.

Go: an interface sealed with an unexported method

Go has no sum types. The FAQ explains why: the Go team “considered adding variant types to Go, but after discussion decided to leave them out because they overlap in confusing ways with interfaces.” The common substitute, used by the standard library’s go/ast package, is an interface with an unexported method, so types outside the package can’t implement it by accident:

package main

import "fmt"

// State is implemented only by the types in this package: isState is unexported.
type State interface {
	isState()
}

type Placed struct{}

type Paid struct{ AmountCents int64 }

type Shipped struct {
	AmountCents int64
	Tracking    string
}

type Cancelled struct{ Reason string }

func (Placed) isState()    {}
func (Paid) isState()      {}
func (Shipped) isState()   {}
func (Cancelled) isState() {}

func label(s State) string {
	switch s := s.(type) {
	case Placed:
		return "placed"
	case Paid:
		return fmt.Sprintf("paid %d", s.AmountCents)
	case Shipped:
		return "shipped " + s.Tracking
	case Cancelled:
		return "cancelled: " + s.Reason
	default:
		panic(fmt.Sprintf("unhandled state %T", s))
	}
}

func main() {
	states := []State{
		Placed{},
		Paid{AmountCents: 1999},
		Shipped{AmountCents: 1999, Tracking: "1Z999"},
		Cancelled{Reason: "out of stock"},
	}
	for _, s := range states {
		fmt.Println(label(s))
	}
}

It prints:

placed
paid 1999
shipped 1Z999
cancelled: out of stock

What this doesn’t give you, measured in our lab with Go 1.26:

  • No exhaustiveness check. Our lab splits this code into an order package and a main package, with no default in the switch. We added a Refunded type to order. go build and go vet were silent, and the type switch fell through at run time. (With the panicking default above, it would panic instead.) go vet has no exhaustiveness analyzer at all.
  • The seal leaks. A struct in another package that embeds the interface, type Rogue struct{ order.State }, gets isState promoted from the embedded field, and so implements State. Ours compiled and ran, and printed outsider got in: main.Rogue. The unexported method stops accidents, not someone determined.
  • A nil interface is a fifth case. var s order.State is valid, and matches none of the cases.
  • Fields inside a case can still be empty. Shipped{AmountCents: 1999} compiles, with Tracking set to "". The same goes for new Shipped(1999, null) in Java and C#. A sum type fixes combinations; newtypes fix the values inside them.

A linter closes the first gap. go-sumtype checks type switches over interfaces you annotate with //go-sumtype:decl State. On the same change it reported: exhaustiveness check failed for sum type 'State': missing cases for Refunded. Its README notes that a default clause turns the check off, unless that default always panics, as ours does. (The exhaustive linter checks switches over enum-like constants, not type switches.) If exhaustiveness matters in a Go codebase, put go-sumtype in CI.

add a Refunded case, leave the switch alone, and build if a Refunded reaches the switch at run time

Diagnostics and output from checks/part09_types/run.py: rustc 1.95.0 (59807616e 2026-04-14), OpenJDK 25, .NET SDK 10.0.302 and 11.0.100-preview.7.26381.103 (C# 15 is a preview), go1.26.2 linux/amd64 and go-sumtype.

Explain it like I’m ten

A vending machine has a slot for coins. If the slot is round and exactly coin-sized, a button can’t get in. Nobody has to check each thing pushed into the slot, because only coins fit.

A machine with a big open flap takes anything: coins, buttons, crisps. Now someone inside has to check every item, every time, and sooner or later they miss one.

Making illegal states unrepresentable means cutting the slot to the shape of a coin. The checks don’t get better; they stop being needed.

The precise version

  • The slot’s shape is the type. A type with fewer possible values leaves less to check.
  • “Only coins fit” is the closed set of cases, and the machine noticing a new coin size it can’t handle is exhaustive matching.
  • Where the analogy breaks: a type limits the shape of data, not every rule. “The amount is positive” or “the tracking number exists at the carrier” still needs a check somewhere. The next two techniques move those checks to one place.

Newtypes: stop mixing up values of the same type

A customer ID and an order ID are both strings. A distance in metres and one in feet are both floating-point numbers. If functions take string and double, the compiler can’t stop you passing one for the other. Martin Fowler calls this Primitive Obsession: programmers are “curiously reluctant to create their own fundamental types which are useful for their domain”, so code does “calculations of physical quantities that ignore units (adding inches to millimeters)”.

It isn’t a style nit. NASA’s Mars Climate Orbiter was lost on 23 September 1999. The investigation board’s report, issued on 10 November 1999, found that a ground software file reported thruster data “in English units of pound-seconds (lbf-s)” where the interface specification required “metric units of Newton-seconds (N-s)”. The navigation software therefore “underestimated the effect on the spacecraft trajectory by a factor of 4.45”. The board also listed contributing causes in the project’s processes, so units weren’t the whole story. And the value crossed a file between two teams, so an in-memory type alone wouldn’t have caught it. That’s the argument for parsing units where data enters a program, which is the next section.

A newtype wraps a primitive in a type of its own. How much it protects you depends on whether code outside can build one without going through your checks.

Rust: a private field makes the constructor the only way in

use std::mem::size_of;
use std::num::NonZeroU32;

mod ids {
    // The field is private: outside this module, parse() is the only way to get a CustomerId.
    pub struct CustomerId(String);

    impl CustomerId {
        pub fn parse(raw: &str) -> Result<CustomerId, String> {
            if raw.starts_with("cus_") && raw.len() > 4 {
                Ok(CustomerId(raw.to_string()))
            } else {
                Err(format!("not a customer id: {raw:?}"))
            }
        }

        pub fn as_str(&self) -> &str {
            &self.0
        }
    }
}

fn main() {
    for raw in ["cus_42", ""] {
        match ids::CustomerId::parse(raw) {
            Ok(id) => println!("ok {}", id.as_str()),
            Err(e) => println!("{e}"),
        }
    }
    println!("size_of Option<u32> = {}", size_of::<Option<u32>>());
    println!(
        "size_of Option<NonZeroU32> = {}",
        size_of::<Option<NonZeroU32>>()
    );
    println!("NonZeroU32::new(0) = {:?}", NonZeroU32::new(0));
}

It prints:

ok cus_42
not a customer id: ""
size_of Option<u32> = 8
size_of Option<NonZeroU32> = 4
NonZeroU32::new(0) = None

Outside the ids module, ids::CustomerId(String::new()) fails with error[E0603]: tuple struct constructorCustomerIdis private. The Book’s section on external traits notes the wrapper costs nothing at run time: “the wrapper type is elided at compile time.”

The last three lines show the standard library doing the same thing. NonZeroU32::new returns an Option, so a zero can’t become a NonZeroU32, and because zero is never a valid value, Option<NonZeroU32> can use the zero bit pattern for None, and takes 4 bytes instead of 8. The docs call this the “null pointer optimization”.

Java: a record’s compact constructor

record CustomerId(String value) {
    CustomerId {
        if (value == null || !value.startsWith("cus_") || value.length() <= 4) {
            throw new IllegalArgumentException("not a customer id: \"" + value + "\"");
        }
    }
}

void main() {
    IO.println("ok " + new CustomerId("cus_42").value());
    try {
        new CustomerId("");
    } catch (IllegalArgumentException e) {
        IO.println("rejected: " + e.getMessage());
    }
    CustomerId[] array = new CustomerId[2];
    IO.println("array element is null: " + (array[0] == null));
}

It prints:

ok cus_42
rejected: not a customer id: ""
array element is null: true

Every CustomerId object went through the check. The gap is null: a CustomerId variable, field or array element can hold no object at all. Optional doesn’t close that gap, and its own documentation has to say that a variable of type Optional “should never itself be null”.

C#: a readonly record struct has doors the constructor doesn’t guard

var good = new CustomerId("cus_42");
Console.WriteLine($"ok {good.Value}");
try
{
    _ = new CustomerId("");
}
catch (ArgumentException e)
{
    Console.WriteLine($"rejected: {e.Message}");
}

CustomerId empty = default;
Console.WriteLine($"default: Value is null = {empty.Value is null}");
var array = new CustomerId[2];
Console.WriteLine($"array element: Value is null = {array[0].Value is null}");
var edited = good with { Value = "" };
Console.WriteLine($"with: Value = \"{edited.Value}\"");

public readonly record struct CustomerId
{
    public CustomerId(string value)
    {
        if (!value.StartsWith("cus_") || value.Length <= 4)
        {
            throw new ArgumentException($"not a customer id: \"{value}\"");
        }
        Value = value;
    }

    public string Value { get; init; }
}

It prints:

ok cus_42
rejected: not a customer id: ""
default: Value is null = True
array element: Value is null = True
with: Value = ""

Three values the constructor never saw. default and array creation skip constructors for every struct: the docs say the default value expression “ignores a parameterless constructor and produces the default value of the structure type”. And with copies the value and then sets the init property directly. To close them, make it a sealed record class with a get-only property and a private constructor, so with can’t set Value and there’s no default instance, only null. Nullable reference types warn about most nulls, not all: the elements of new Email[2] are null with no warning. Those warnings are “entirely a compile-time feature”: string and string? are the same type at run time, though some frameworks read the annotations, as ASP.NET Core MVC’s validation does, treating non-nullable properties as required.

One more door in every language: serializers. A JSON library that sets properties or fields directly, or calls a public constructor you didn’t mean for it, builds values your checks never saw. Parse deserialized data like any other input (see the next section), or point the serializer at the checked constructor.

Go: a defined type stops accidents, not conversions

package main

import "fmt"

type Meters float64
type Feet float64

func climb(height Meters) Meters { return height + 100 }

type OrderID string

func main() {
	var f Feet = 30
	fmt.Println(climb(5))         // an untyped constant is accepted
	fmt.Println(climb(Meters(f))) // a conversion is always allowed, right or wrong
	var id OrderID
	fmt.Printf("zero value: %q\n", id)
}

It prints:

105
130
zero value: ""

The spec says a defined type “is different from any other type, including the type it is created from”, so climb(f) fails: cannot use f (variable of float64 type Feet) as Meters value in argument to climb. But anyone can write Meters(f), and every type has a zero value, so a Go newtype can’t force a check. For that, use a struct with an unexported field in its own package and a constructor function; code outside the package can still declare the zero value, so methods should treat it as invalid.

A newtype is a name, not a proof

Alexis King, whose essay the next section builds on, drew the line in 2020: “On its own, a newtype is just a name.” Its safety comes from “abstraction boundaries”: if the constructor isn’t exported, only the defining module can break the invariant. Keep two rungs apart in your head:

  • A sum type makes illegal combinations impossible to write. There’s no combination left to check.
  • A newtype with a guarded constructor makes them impossible to build outside one module. Inside it, you still have to get the check right.

Parse, don’t validate

Alexis King’s 2019 essay “Parse, don’t validate” names the habit that makes types like these work in practice. Both a validator and a parser check the input. The difference, in King’s words, “lies almost entirely in how information is preserved”:

  • A validator checks and throws the knowledge away. bool IsValidEmail(string s) returns true, and the caller still holds a string. Every later function that needs a valid email either checks again or trusts that someone did.
  • A parser checks and returns a more precise type. Email.TryParse(string raw, out Email? email) gives you an Email or nothing. Every later function takes Email, and can’t be handed an unchecked string.
using System.Diagnostics.CodeAnalysis;

string[] inputs = ["ada@example.com", "not an email"];
foreach (var raw in inputs)
{
    Console.WriteLine(Email.TryParse(raw, out var email) ? Welcome(email) : $"rejected: {raw}");
}

// Takes an Email, not a string: there's nothing left to check here.
static string Welcome(Email to) => $"welcome sent to {to.Value}";

public sealed record Email
{
    private Email(string value) => Value = value;

    public string Value { get; }

    public static bool TryParse(string raw, [NotNullWhen(true)] out Email? email)
    {
        var at = raw.IndexOf('@');
        email = at > 0 && at < raw.Length - 1 && !raw.Contains(' ') ? new Email(raw) : null;
        return email is not null;
    }
}

It prints:

welcome sent to ada@example.com
rejected: not an email

(The email rule is deliberately simple. Real address validation is a rabbit hole; the shape of the code is the point.)

King’s advice is to “Push the burden of proof upward as far as possible, but no further”, ideally “at the boundary of your system, before any of the data is acted upon”. In a service, the boundary is where data arrives: the HTTP request body, the message from the queue, the row from the database, the file from another team. Parse it there into domain types, reject it there if it doesn’t fit, and let the inside of the program work only with types that can’t be wrong.

The opposite has a name from language-theoretic security research: shotgun parsing, where checks are “spread across processing code—throwing a cloud of checks at the input, and hoping, without any systematic justification, that one or another would catch all the “bad” cases.” Its worst consequence is that invalid input gets partly processed before a late check rejects it, leaving the program’s state “difficult to accurately predict”.

Typestate: make the wrong call not compile

Sum types stop an order from being in an impossible state. They don’t stop code from calling ship() on an order that’s only placed, because ship takes any Order, and the check happens at run time.

Typestate goes one step further: each state is its own type, and each type has only the operations valid in that state. Robert Strom and Shaula Yemini introduced the term in 1986, as a compiler analysis: where “the type of a data object determines the set of operations ever permitted on the object, typestate determines the subset of these operations which is permitted in a particular context.” Rust programmers get a similar effect with ordinary types, and the Rust Embedded Book calls it “Typestate Programming”.

struct Placed;

struct Paid {
    amount_cents: u64,
}

struct Shipped {
    tracking: String,
}

impl Placed {
    fn pay(self, amount_cents: u64) -> Paid {
        Paid { amount_cents }
    }
}

impl Paid {
    fn ship(self, tracking: &str) -> Shipped {
        println!("shipping an order paid {}", self.amount_cents);
        Shipped {
            tracking: tracking.to_string(),
        }
    }
}

fn main() {
    let order = Placed;
    let paid = order.pay(1999);
    let shipped = paid.ship("1Z999");
    println!("tracking {}", shipped.tracking);
}

It prints:

shipping an order paid 1999
tracking 1Z999

Two mistakes, two compile errors, provided code can’t build a Paid or Shipped directly. In real code, put the state types in their own module with private fields, so pay() is the only way to get a Paid, and don’t derive Clone or Copy on them, which would bring reuse back. Placed has no ship method, so shipping an unpaid order fails with error[E0599]: no method namedshipfound for structPlacedin the current scope. And ship takes self by value, so the Paid is moved into it. Shipping the same paid order twice fails:

struct Paid;
struct Shipped;

impl Paid {
    fn ship(self) -> Shipped {
        Shipped
    }
}

fn main() {
    let paid = Paid;
    let _first = paid.ship();
    let _again = paid.ship();
}
error[E0382]: use of moved value: `paid`
Rust typestate: one type per state, and each transition consumes the old value Placedpay(amount) Paidship(tracking) Shippedno transitions pay() ship() order error[E0599]: no method named `ship` found for struct `Placed` in the currentscope error[E0382]: use of moved value: `paid` 1. A new order is a Placed. Placed has one method, pay(); it has no ship() at all 2. order.ship() on a Placed doesn't compile: the type has no such method 3. pay() takes the Placed by value and returns a Paid; the old value is gone 4. ship() consumes the Paid. Shipping the same Paid twice doesn't compile

The error messages are rustc 1.95’s output for checks/part09_types/rust/typestate.rs with one line added, compiled by checks/part09_types/run.py.

The second error is what makes Rust special here. In C#, Java and Go you can write the same classes, with Pay() returning a Paid and Ship() returning a Shipped, and the first mistake becomes a compile error there too. But nothing stops code from keeping the old Paid reference and calling Ship() on it again. Of our four languages, only Rust tracks moves, so only Rust can make “use this value once” a compile-time rule. In the other three languages, check it at run time, or make the transition a method on a store that loads and saves the state atomically.

When is typestate worth it? For protocols with a strict order of steps that all happen in one piece of code: a builder that needs certain fields set, a connection that must be opened before use, a hardware pin configured before it’s read. For a business entity like an order, whose state is saved in a database and changed by different requests hours apart, a sum type is usually the better fit: each request loads whatever state is stored, and a type can’t know that in advance.

Across languages

C# Java Go Rust
Sum type abstract record with a private constructor and nested cases (leaks through the copy constructor; a class doesn’t); C# 15 closed and union (preview) sealed interface with record cases (Java 17) interface with an unexported method; the seal leaks through embedding enum
Missing case in a match warning CS8509; with a closed class (C# 15) it names the case compile error for switch expressions and pattern switches nothing; go-sumtype linter reports it compile error E0004
Match-all that hides new cases _ arm default default _ arm; #[non_exhaustive] forces one on callers
Absence T?: warnings only for references, and not for every null Optional<T>, still nullable nil, zero values Option<T>
Newtype that can’t be bypassed sealed record class with a private constructor, still nullable (default, arrays and with bypass structs) record with a compact constructor; the reference can still be null struct with an unexported field in its own package; the zero value remains struct with a private field
Typestate wrong method is a compile error; reusing an old state isn’t same as C# same as C# both are compile errors, through moves

A functional footnote: in functional languages such as OCaml, Haskell and F#, sum types and exhaustive matching are the everyday way to model data. Scott Wlaschin’s 2013 F# post “Designing with types: Making illegal states unrepresentable” borrows Minsky’s phrase for a contact that must have an email address, a postal address, or both, modelled as three cases, so “the fourth possible case (with no email or postal address at all) is not allowed.”

Trade-offs

  • Precise types cost effort at the edges. Every boundary needs a parser, and every serializer, ORM mapping and API schema needs to understand the new types. For a short script or a prototype, a string may be the right call.
  • Sum types make adding cases loud and adding operations easy. That’s the expression problem from Part 8. If outside code must add new cases, like plugins, use an interface instead, and accept that no compiler can list them all.
  • Stored data outlives types. A database row written last year may hold a state your current enum no longer has. Parse rows on load like any other input, and plan migrations for removed cases.
  • Some rules don’t fit in a type. “A seat can’t be sold twice, even when two requests arrive together” needs a database constraint or a conditional update, not a type (Part 5). Types remove single-value mistakes; they don’t coordinate many writers.
  • Don’t over-model. A type per rule ends in wrappers nobody can read. Model the states and values that bugs have actually confused, and the ones the business names.
  • In C# and Go, part of the guarantee is tooling. Without go-sumtype in CI, or, once C# 15’s closed ships, WarningsAsErrors for CS8509, the check exists only for developers who read warnings.

Common mistakes

  • Adding a default or _ arm “to be safe”. It turns every future case into a silent fall-through. Over a closed set, leave it out; in C# 14, where you need one, make it throw.
  • Booleans for states. IsPaid, IsShipped and IsCancelled make 8 combinations for 4 states before any data is added. Use one state field of a sum type.
  • Validating deep inside the program. The same string gets checked in three services and trusted in a fourth. Parse once at the boundary into a type that proves it.
  • Newtypes with public constructors or setters. new CustomerId("") or CustomerId(x) in Go goes straight past the rule. The constructor has to be the only way in.
  • Believing C# struct newtypes are always valid. default, array creation and with all bypass the constructor, as the run above showed.
  • Trusting a hand-made seal as a guarantee. Go’s unexported method leaks through embedding, and a C# record hierarchy through its copy constructor. Treat unknown types in a switch as a bug, make the fallback throw, and lint.
  • Using Optional or nullable annotations as proof. Java’s Optional reference can be null, and C#’s annotations don’t change the run-time type. They’re documentation that tooling checks, not a type system guarantee.

Interview questions

Try to answer each one before opening the model answer.

1. What does “make illegal states unrepresentable” mean? Give an example.

Show a strong answer
  • Meaning: design types so values that break business rules can’t be constructed, instead of checking for them wherever they’re used. The phrase is Yaron Minsky’s.
  • Example: an order with IsPaid, IsShipped, IsCancelled and three optional fields can hold 64 combinations, of which 4 are legal. As a sum type with four cases, each carrying only its own data, it holds exactly the 4.
  • Payoff: fewer defensive checks, and a compiler that lists every place to update when a state is added.
  • Limit: types capture shape. Rules across many values, such as not selling a seat twice under concurrency, still need transactions or constraints.

Likely follow-up: “What if the rule can’t be expressed as a type?” Use a newtype with a guarded constructor, so the check lives in one place, and parse at the boundary.

2. What is a sum type, and how do you get one in C#, Java, Go and Rust?

Show a strong answer
  • Definition: a value that is exactly one of a fixed set of cases, each with its own data. A product type (record, struct) has all of its fields; a sum type has one case.
  • Rust: enum with data-carrying variants; match must be exhaustive (error E0004).
  • Java: a sealed interface with record implementations (Java 17), and pattern switch (Java 21), which is checked for exhaustiveness.
  • C#: an abstract record whose private constructor limits subclasses to nested records. The compiler still warns CS8509, so a throwing _ arm is needed. C# 15’s closed classes and union types, in preview in .NET 11, let the compiler check it, still as a warning.
  • Go: no sum types. An interface with an unexported method approximates one; the compiler doesn’t check type switches, and the go-sumtype linter does.

Likely follow-up: “Why doesn’t Go have them?” The FAQ says they “overlap in confusing ways with interfaces”, and much of the need is covered by interfaces and type switches.

3. Why is a default branch in a switch over a sealed type a problem?

Show a strong answer
  • It hides new cases. The switch is exhaustive today because of default, so when a case is added the compiler has nothing to report, and the new case silently takes the default path. Our Java lab printed unknown for a new Refunded order.
  • JEP 441’s words: “A match-all clause risks sweeping exhaustiveness errors under the rug.”
  • Better: list every case and no default, so adding one is a compile error at every switch.
  • When you must have one: C# 14 requires a _ arm to avoid a warning over a hand-closed hierarchy. Make it throw, such as UnreachableException, so it fails loudly.

Likely follow-up: “What about a library whose enum will grow?” That’s what Rust’s #[non_exhaustive] is for: it forces callers to handle unknown future cases, knowingly giving up the check.

4. What’s the difference between parsing and validating?

Show a strong answer
  • Validating checks input and returns nothing you can use: bool IsValid(string). The caller still holds the raw type, so later code checks again or trusts blindly.
  • Parsing checks input and returns a more precise type or a failure: bool TryParse(string, out Email), Result<CustomerId, Error>. Later code takes Email, so it can’t receive unchecked data.
  • Where: at the boundary, as early as possible: request bodies, messages, database rows, files.
  • Why it matters for security: checks scattered through processing code, “shotgun parsing” in LangSec terms, let invalid input be partly processed before it’s caught.

Likely follow-up: “Where do you put the parser in a layered service?” In the adapter that receives the data, such as the HTTP handler or message consumer, turning DTOs into domain types before calling the application core.

5. When does a newtype actually protect an invariant?

Show a strong answer
  • Only when its constructor is the only way to build it. A private field in Rust; a sealed record class with a private constructor in C#; a record’s compact constructor in Java; an unexported field and constructor function in Go.
  • Known bypasses: C# structs via default, array creation and with on an init property; null references in C# and Java; Go conversions like Meters(f) and zero values; and in every language, serializers that set fields directly.
  • What it still gives without that: protection against mixing up values, like metres and feet, or customer and order IDs.
  • Framing: Alexis King: “On its own, a newtype is just a name.” Its safety comes from the abstraction boundary.

Likely follow-up: “Would newtypes have saved the Mars Climate Orbiter?” Not alone. The wrong units crossed a file interface between teams, and the board also named process causes. Parsing that file into typed units, with the unit written into the format, would likely have caught it.

6. What is typestate, and when would you use it?

Show a strong answer
  • Definition: each state is a separate type, with only the operations valid in that state, and transitions return the next state’s type. Strom and Yemini introduced the term in 1986 as a compiler analysis; in Rust it’s a pattern with ordinary types.
  • What the compiler catches: calling an operation in the wrong state (no such method). In Rust, transitions that take self by value also stop reuse of the old state (E0382, use of moved value). C#, Java and Go catch the first but not the second.
  • Good fits: builders, connections, protocol handshakes, hardware configuration: sequences that happen in one piece of code.
  • Poor fits: entities stored in a database and changed by separate requests, where the current state is only known at run time. Use a sum type there.

Likely follow-up: “How would you get the second guarantee in Java?” At run time: a state field checked on every transition, or an atomic compare-and-set in the database, such as UPDATE ... WHERE state = 'paid'.

7. How would you redesign a class full of nullable fields and boolean flags?

Show a strong answer
  • List the real states with the business, and which data each has. Count the combinations the current type allows to show the gap.
  • Replace the flags with one sum type, each case carrying only its fields. Replace validated primitives, like IDs, emails and amounts, with newtypes.
  • Parse at the edges: the database mapper and API layer turn rows and DTOs into the new types, and reject or quarantine bad stored data.
  • Migrate incrementally: introduce the new type behind the old API, move callers one by one, turn on exhaustiveness checks (go-sumtype in Go; in C#, a throwing _ arm today and WarningsAsErrors for CS8509 once closed ships), then delete the flags.
  • Keep persistence in mind: often a state column plus nullable columns, with a database CHECK constraint that mirrors the rules.

Likely follow-up: “How do you store a sum type in SQL?” A discriminator column with per-case nullable columns and CHECK constraints, a table per case, or a JSON column with a type tag. Each is a trade-off between constraints and flexibility.

8. Does C#’s nullable reference types feature make null references impossible?

Show a strong answer
  • No. Microsoft’s docs say it’s “entirely a compile-time feature”: string and string? are both System.String at run time.
  • It warns, when a possibly-null value is used as non-null. Warnings can be ignored, suppressed with !, or missed by the analysis, such as a struct created with default or the elements of a new array.
  • Data from outside (deserialization, reflection, older libraries without annotations) can still bring nulls in. Some frameworks do read the annotations: ASP.NET Core MVC’s validation treats non-nullable properties as if they had [Required(AllowEmptyStrings = true)].
  • To make it stronger: enable it everywhere, treat nullable warnings as errors, and parse external data at the boundary.
  • Compare: Rust’s Option<T> is a real type: a T can’t be absent, and you can’t use an Option<T> as a T without handling None.

Likely follow-up: “What does Java offer?” Optional<T> for return values, and annotations checked by external tools. The Optional reference itself can still be null.

Sources

What to remember

  • Count the combinations your type allows, then the ones the business allows. The gap is the checks your code must never forget. Our flag-based order had 64 and 4.
  • A sum type (Rust enum, Java sealed interface of records, C# closed hierarchy, a sealed Go interface) holds only the legal cases, each with only its own data.
  • Exhaustive matching turns “we added a state” into a list of compile errors. Rust and Java give errors; C# gives warnings to promote to errors; Go needs go-sumtype.
  • Never add default or _ over a closed set, unless the language forces it, as C# 14 does; then make it throw.
  • A sum type removes illegal combinations; newtypes guard the values inside them. A newtype protects a rule only if its constructor is the only way in, and C# structs, Go types, nulls and serializers all have doors around it.
  • Parse at the boundary into precise types; don’t validate and keep the raw string.
  • Typestate makes wrong calls not compile. Only Rust also stops reuse of an old state.

Don’t check for the impossible. Make it impossible to write down.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.