ADTs and Pattern Matching: Make Illegal States Unrepresentable (Part 6 of the Functional Programming Series)
Part 6 of the Functional Programming in Practice series. Parts 4 and 5 used Option and Either, which were both built from the same shape: a type that is "this OR that," with a tag to tell them apart. This part names that shape and shows what it unlocks.
Hey there, Coding Chefs! 👨💻
Have you ever debugged a state that should have been impossible? An order that was somehow both paid: false and had a shippedAt timestamp. A user object that was loggedIn: true but had a null session. A payment that was status: "declined" yet carried a transactionId. Each of these is a contradiction that your data model cheerfully allowed, and now you are writing defensive code to detect and untangle a situation that should never have existed in the first place.
Here is the functional move: stop detecting illegal states and start making them impossible to construct. If your types literally cannot represent the contradiction, you never write the bug, and you never write the defensive code to catch it either. The compiler refuses to let the bad state exist.
The tools for this are algebraic data types (ADTs) and pattern matching. You have actually been using ADTs since we met Option, you just did not have the name. By the end of this part you will know how to model a domain so tightly that bad states will not compile, and how to take those types apart safely and exhaustively. Let's get cracking.
Two Ways to Combine Types
The foundation told us a type is a set of values. So a fair question is: how do we build new types out of existing ones? It turns out there are exactly two fundamental ways to combine them, and every data structure you have ever written is some mix of the two.
Product types: this AND that
The first idea is to pair values together. Take an integer and a boolean, and make a new type whose values are every possible pairing: (3, true), (3, false), (4, true), and so on. A value of this new type holds an integer and a boolean, both at once.
This is called a product type. The name comes from cardinality (the value count we defined at the start): the number of values in the combined type is the cardinality of the first times the cardinality of the second. Integer times boolean. Hence "product."
In TypeScript you already write product types constantly, as objects and tuples:
// product type as an object: a user has a name AND an age AND an address
type UserRecord = {
name: string;
age: number;
address: { city: string; country: string };
};
// product type as a tuple: a name AND an age
type UserTuple = [string, number];
Every field is present at the same time. That is "and." Easy, familiar.
Sum types: this OR that
The second idea is different. Instead of pairing, we put every value from both types side by side into one combined type, so a value is either one or the other, not both.
Here is the catch that makes it interesting. If you just dump all the integers and all the booleans into one set, then when you receive a value you cannot easily tell whether it is an integer or a boolean. You have lost track of which world it came from. Imagine combining two complex object types instead of integer and boolean, and the ambiguity gets much worse.
The fix is to tag each value with which variant it belongs to. The combined, tagged type is called a sum type, and each tagged group inside it is called a variant. The name again comes from cardinality: the count of values is the cardinality of the first type plus the cardinality of the second. Hence "sum." (You will also hear sum types called tagged unions, discriminated unions, or coproducts, all the same thing.)
In TypeScript, a sum type is a union with a discriminant tag:
// sum type: a value is a number variant OR a boolean variant, never both
type NumberOrBool =
| { tag: "num"; value: number }
| { tag: "bool"; value: boolean };
And now the recognition: this is exactly the shape of Option and Either from the last two parts. Option was Some OR None. Either was Left OR Right. Both were sum types, tagged unions where a value is one variant or another. You have been building sum types for two whole articles.
What an ADT Actually Is
Put the two together and you have the definition. An algebraic data type is simply a composite type built using sum and product operations: products for "this and that," sums for "this or that," nested however you need.
One quick disambiguation, because it trips people up. ADT here means algebraic data type. There is a different, unrelated term, abstract data type, which is about hiding implementation behind an interface (like a stack with push and pop). Same acronym, totally different concept. When functional programmers say ADT, they mean the algebraic one: types composed with sums and products.
That is the whole vocabulary. Now let's use it to make bad states impossible.
Making Illegal States Unrepresentable
Back to the contradictory order. The loose model is a product type where every field is independent:
// the bug factory: every combination is allowed, including the impossible ones
type Order = {
status: string; // "pending" | "paid" | "shipped" | ... but nothing enforces it
paidAt: Date | null;
shippedAt: Date | null;
trackingNumber: string | null;
};
This type permits status: "pending" with a shippedAt set. It permits paid with a null paidAt. Every field varies independently, so the type's value set is full of contradictions, and you are left writing runtime checks to reject the ones that should never happen.
Now model it as a sum type instead, where each variant carries exactly the data that makes sense for that state and nothing else:
type Order =
| { tag: "pending"; items: Item[] }
| { tag: "paid"; items: Item[]; paidAt: Date }
| { tag: "shipped"; items: Item[]; paidAt: Date; shippedAt: Date; tracking: string }
| { tag: "delivered"; items: Item[]; paidAt: Date; shippedAt: Date; deliveredAt: Date };
Look at what just happened. A pending order has no paidAt, because the type does not give it one. A shipped order is guaranteed to have both a paidAt and a tracking, because the type requires them. There is no way to construct an order that is shipped but unpaid, the type simply has no such shape. The contradictory states are not validated against, they are unrepresentable. The bug factory is shut down at the type level.
This is the heart of domain modeling in functional programming. Spend your effort shaping types so that only legal states can exist, and a huge swath of defensive runtime code, and the bugs that hide in it, evaporates.
Pattern Matching: Taking ADTs Apart
Building tightly-typed sum types is half the story. The other half is using the data inside them, which means looking at each variant case by case, pulling out its data, and handling it. That process is called pattern matching.
Conceptually, to turn an Option<boolean> into a string, you consider each variant. For None, return some string. For Some, reach in, grab the boolean, and convert it. You match on the tag, extract, and transform.
There is a rule that matters enormously: your pattern match must be exhaustive. It has to cover every variant of the sum type, leaving none out. The reason ties back to what a function is. A pattern match is really a function from the sum type to some result. Functions in functional programming must be total, defined for every input. If you skip a variant, your match is partial, and partial functions are exactly what we are trying to eliminate. Miss a case and you have reintroduced the hole.
Pattern Matching in TypeScript (and Its Honest Limits)
Here is the awkward truth. Functional languages have native pattern-matching syntax, a clean construct for matching and extracting in one expression. TypeScript does not, yet. There is a TC39 proposal for pattern matching in JavaScript, but at the time of writing it is still early-stage. And switch and if in JavaScript are statements, not expressions, so they do not return a value the way a true match should.
So what do we actually do today? Three practical tools.
First, the discriminated-union switch with an exhaustiveness check. This is the workhorse. You switch on the tag, handle each case, and add a default that assigns the value to a variable of type never. Because never is the empty set, this only compiles if every real case has already been handled. The moment you add a new variant and forget to handle it, the never assignment fails to compile, and the compiler points you straight at the gap.
const assertNever = (x: never): never => {
throw new Error(`unhandled variant: ${JSON.stringify(x)}`);
};
const describe = (order: Order): string => {
switch (order.tag) {
case "pending": return "Awaiting payment";
case "paid": return "Paid, preparing to ship";
case "shipped": return `Shipped, tracking ${order.tracking}`;
case "delivered": return `Delivered on ${order.deliveredAt.toDateString()}`;
default: return assertNever(order); // compile error if a case is missed
}
};
Inside each case, TypeScript narrows the type, so order.tracking is available in the shipped branch and nowhere else. The compiler gives you exactly the data that variant carries, and forbids the data it does not. That is exhaustiveness and safe extraction working together.
Second, for simple two-way matches, the humble ternary does the job and actually returns a value:
const optionToString = (o: Option<boolean>): string =>
o.tag === "none" ? "nothing" : `${o.value}`;
Third, for richer matching with less ceremony, the ts-pattern library gives you a genuine expression-based match with exhaustiveness checking, closer to what native functional languages offer. Worth reaching for when your matches get complex.
A Real Example: A Payment Authorization Flow
Let's model a payment authorization as a sum type so impossible transitions cannot be built, then match on it exhaustively.
type Payment =
| { tag: "pending"; amount: number }
| { tag: "authorized"; amount: number; authCode: string }
| { tag: "declined"; amount: number; reason: string }
| { tag: "refunded"; amount: number; authCode: string; refundedAt: Date };
const render = (p: Payment): string => {
switch (p.tag) {
case "pending": return `₦${p.amount} awaiting authorization`;
case "authorized": return `₦${p.amount} authorized (${p.authCode})`;
case "declined": return `₦${p.amount} declined: ${p.reason}`;
case "refunded": return `₦${p.amount} refunded on ${p.refundedAt.toDateString()}`;
default: return assertNever(p);
}
};
A declined payment is forced to carry a reason. A refunded one is forced to carry the original auth code and a refund date. You cannot construct a declined payment with an auth code, because the declined variant has no such field. And if the business later adds a disputed state, the assertNever makes render fail to compile until you handle it. The type system becomes your checklist.
But Everything Has a Cost
The honest caveats.
You can over-model. Not every three-field object needs to be a five-variant sum type. Modeling has a cost in upfront thought and ceremony. The payoff is real when a domain genuinely has distinct states with distinct data and dangerous illegal combinations. For a simple bag of always-present fields, a plain product type is correct and a sum type is over-architecture. Judgment, as always.
TypeScript's safety is good, not perfect. The never exhaustiveness trick is a manual pattern you have to remember to add. Forget the default: assertNever(...) and you lose the compile-time guarantee. Native functional languages give you this for free; in TypeScript it is a discipline.
Sum types can feel heavy at first. Writing tagged unions and switches is more upfront structure than a loose object. The trade is that the structure pays you back every time a bad state fails to compile instead of failing in production.
The Honest Conclusion
Every type you build is some combination of two operations: product, for "this and that," and sum, for "this or that." That is what algebraic data types are, and Option and Either were sum types all along. Once you see types as sets you can add and multiply, you gain a new power: you can shape a type so that only legal states live inside it, and the illegal ones, the unpaid-but-shipped contradictions, simply have no representation. The bug cannot be written.
Pattern matching is how you safely take those types apart, considering every variant, extracting exactly the data each one carries, and the compiler holds you to handling all of them. In TypeScript you get there with discriminated unions, a never-typed exhaustiveness check, and ts-pattern when you want more.
Spend your effort on the types. A well-shaped ADT turns a category of runtime bugs into compile errors, which is the cheapest place a bug can ever die.
The best bug is the one the compiler will not let you write. Model your domain so the illegal states have nowhere to live.
Next up: We have met a lot of named patterns, Option, Either, sum types, composition. Next we take a 20-minute detour into category theory, the one simple idea (objects, arrows, and composition) that quietly underpins all of it, and that makes every scary word still to come, Functor, Monoid, Monad, suddenly obvious.