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.
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:
- A closed set of cases. Nobody outside the definition can add a fifth.
- 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
orderpackage and amainpackage, with nodefaultin the switch. We added aRefundedtype toorder.go buildandgo vetwere silent, and the type switch fell through at run time. (With the panickingdefaultabove, it would panic instead.)go vethas no exhaustiveness analyzer at all. - The seal leaks. A struct in another package that embeds the interface,
type Rogue struct{ order.State }, getsisStatepromoted from the embedded field, and so implementsState. Ours compiled and ran, and printedoutsider got in: main.Rogue. The unexported method stops accidents, not someone determined. - A nil interface is a fifth case.
var s order.Stateis valid, and matches none of the cases. - Fields inside a case can still be empty.
Shipped{AmountCents: 1999}compiles, withTrackingset to"". The same goes fornew 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.
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 astring. 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 anEmailor nothing. Every later function takesEmail, 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`
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
closedships,WarningsAsErrorsfor CS8509, the check exists only for developers who read warnings.
Common mistakes
- Adding a
defaultor_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,IsShippedandIsCancelledmake 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("")orCustomerId(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 andwithall 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
Optionalor nullable annotations as proof. Java’sOptionalreference 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,IsCancelledand 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:
enumwith data-carrying variants;matchmust be exhaustive (error E0004). - Java: a
sealedinterface withrecordimplementations (Java 17), and patternswitch(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’sclosedclasses anduniontypes, 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 printedunknownfor a newRefundedorder. - 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 asUnreachableException, 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 takesEmail, 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 andwithon aninitproperty; null references in C# and Java; Go conversions likeMeters(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
selfby 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 andWarningsAsErrorsfor CS8509 onceclosedships), then delete the flags. - Keep persistence in mind: often a
statecolumn plus nullable columns, with a databaseCHECKconstraint 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”:
stringandstring?are bothSystem.Stringat 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 withdefaultor 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: aTcan’t be absent, and you can’t use anOption<T>as aTwithout handlingNone.
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
- Labs:
system-design/checks/part09_states.py(the 64 combinations) andsystem-design/checks/part09_types/(the compiler messages and run results quoted in the prose, from rustc 1.95, OpenJDK 25, .NET SDK 10.0.302, .NET SDK 11.0.100-preview.7, Go 1.26 and go-sumtype); the C#, Java, Go and Rust programs above are run by the series’ code verifiers - Yaron Minsky, Effective ML Revisited, Jane Street blog, 2011
- Alexis King, Parse, don’t validate, 2019, and Names are not type safety, 2020
- F. Momot, S. Bratus, S. M. Hallberg, M. L. Patterson, The Seven Turrets of Babel: A Taxonomy of LangSec Errors and How to Expunge Them, 2016
- R. E. Strom and S. Yemini, “Typestate: A Programming Language Concept for Enhancing Software Reliability”, IEEE Transactions on Software Engineering, SE-12(1), 1986; the Rust Embedded Book, Typestate Programming
- Tony Hoare, “Null References: The Billion Dollar Mistake”, QCon London 2009 (abstract, archived)
- Rust: the Book, enums and Option, match, newtypes, the newtype pattern for external traits; the Reference, non_exhaustive; NonZero
- Java: JEP 409, sealed classes, JEP 441, pattern matching for switch, JEP 440, record patterns, JEP 395, records, Optional
- C#: pattern matching warnings, CS8509, switch expression, nullable reference types, records, structs, What’s new in C# 15, union types, the unions proposal, ASP.NET Core model validation
- Go: FAQ, variant types, the specification, go/ast source; go-sumtype and exhaustive (community linters)
- Martin Fowler, Refactoring, 2nd edition, 2018, “Primitive Obsession” (InformIT excerpt)
- NASA, Mars Climate Orbiter Mishap Investigation Board Phase I Report, 10 November 1999
- Scott Wlaschin, Designing with types: Making illegal states unrepresentable, 2013
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
defaultor_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.