Making Illegal States Unrepresentable
In the last lesson you learned to model data with discriminated unions. This lesson pushes that principle to its logical conclusion: if a state should not exist, make it impossible to construct.
Tony Hoare called null his "billion-dollar mistake." He was right. But null is just one instance of a broader problem: types that allow values that should not exist. This lesson gives you the tools to eliminate entire categories of bugs by making them unrepresentable.
The Null Problem
null means "nothing is here." But "nothing is here" is ambiguous. Does it mean:
- The value was not provided?
- The value was provided but was empty?
- The lookup failed?
- The operation has not completed yet?
- Something went wrong?
null conflates all of these into one value. The caller cannot distinguish between them without reading the implementation to understand which meaning applies in each context.
function findUser(id: string): User | null {
// null means... what?
// - User does not exist? (normal)
// - Database connection failed? (error)
// - ID was malformed? (validation failure)
// The caller cannot tell.
}TypeScript's strictNullChecks helps by forcing you to handle null, but it does not solve the ambiguity. You still do not know why the value is null.
The Option Type
An Option (also called Maybe in Haskell, Optional in Java) explicitly represents the presence or absence of a value. It replaces null with a type that carries intent.
type Option<T> =
| { readonly kind: "some"; readonly value: T }
| { readonly kind: "none" };
// Construction
function some<T>(value: T): Option<T> {
return { kind: "some", value };
}
function none<T>(): Option<T> {
return { kind: "none" };
}This is a discriminated union. The kind field tells you whether a value exists. Pattern matching makes it impossible to access the value without checking first.
function findUser(id: string): Option<User> {
const user = database.get(id);
if (!user) return none();
return some(user);
}
// Caller MUST handle both cases
const result = findUser("123");
switch (result.kind) {
case "some": console.log(result.value.name); break;
case "none": console.log("User not found"); break;
}
// Cannot access result.value without checking kind first.
// The type system enforces it.Option Combinators
The real power of Option comes from combinators: functions that transform options without unwrapping them.
function map<T, U>(option: Option<T>, fn: (value: T) => U): Option<U> {
if (option.kind === "none") return none();
return some(fn(option.value));
}
function flatMap<T, U>(option: Option<T>, fn: (value: T) => Option<U>): Option<U> {
if (option.kind === "none") return none();
return fn(option.value);
}
function unwrapOr<T>(option: Option<T>, defaultValue: T): T {
if (option.kind === "none") return defaultValue;
return option.value;
}These let you chain operations on optional values without nested null checks:
// Without Option: nested null checks
function getUserDisplayName(id: string): string {
const user = findUser(id);
if (user === null) return "Unknown";
const profile = getProfile(user.profileId);
if (profile === null) return user.username;
return profile.displayName ?? user.username;
}
// With Option: chained transformations
function getUserDisplayName(id: string): string {
return unwrapOr(
flatMap(findUser(id), user =>
map(getProfile(user.profileId), profile => profile.displayName)
),
"Unknown"
);
}
// Each step either produces a value or short-circuits to none.
// No null checks. No nesting. Each function returns Option.The Result Type
Option answers "is there a value?" Result answers "did the operation succeed, and if not, why?"
type Result<T, E> =
| { readonly kind: "ok"; readonly value: T }
| { readonly kind: "err"; readonly error: E };
function ok<T, E>(value: T): Result<T, E> {
return { kind: "ok", value };
}
function err<T, E>(error: E): Result<T, E> {
return { kind: "err", error };
}Result replaces exceptions for expected failure cases. Exceptions are for unexpected failures (bugs, out-of-memory). Expected failures (validation errors, not found, permission denied) should be values, not exceptions.
// Exceptions: caller can forget to catch
function parseAge(input: string): number {
const n = parseInt(input, 10);
if (isNaN(n)) throw new Error("Not a number");
if (n < 0 || n > 150) throw new Error("Age out of range");
return n;
}
// Caller might not catch. Runtime crash.
// Result: caller MUST handle the error
type ParseError = "not_a_number" | "out_of_range";
function parseAge(input: string): Result<number, ParseError> {
const n = parseInt(input, 10);
if (isNaN(n)) return err("not_a_number");
if (n < 0 || n > 150) return err("out_of_range");
return ok(n);
}
// Caller:
const result = parseAge(userInput);
switch (result.kind) {
case "ok": console.log(`Age: ${result.value}`); break;
case "err":
switch (result.error) {
case "not_a_number": console.log("Please enter a number"); break;
case "out_of_range": console.log("Age must be 0-150"); break;
}
}
// Every error case is visible. Every error case is handled.
// Adding a new error type → compile error if not handled.Result Combinators
Same idea as Option combinators:
function mapResult<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
if (result.kind === "err") return result;
return ok(fn(result.value));
}
function flatMapResult<T, U, E>(result: Result<T, E>, fn: (value: T) => Result<U, E>): Result<U, E> {
if (result.kind === "err") return result;
return fn(result.value);
}
function unwrapResultOr<T, E>(result: Result<T, E>, defaultValue: T): T {
if (result.kind === "err") return defaultValue;
return result.value;
}Combining Option and Result
In real code, you chain these together to handle complex flows without exceptions or null:
type AppError = "user_not_found" | "profile_not_found" | "invalid_age";
function processUserAge(userId: string): Result<number, AppError> {
const userResult = findUserResult(userId);
if (userResult.kind === "err") return userResult;
const profileResult = getProfileResult(userResult.value.profileId);
if (profileResult.kind === "err") return profileResult;
const ageResult = parseAge(profileResult.value.ageInput);
if (ageResult.kind === "err") return err("invalid_age");
return ok(ageResult.value);
}
// Every failure path is explicit. No try/catch. No null checks.
// The return type tells the caller exactly what can go wrong.Exhaustive Pattern Matching
When you combine discriminated unions with exhaustive switches, TypeScript becomes a compiler for your business rules.
type OrderState =
| { readonly kind: "draft"; readonly items: readonly Item[] }
| { readonly kind: "submitted"; readonly items: readonly Item[]; readonly submittedAt: Date }
| { readonly kind: "paid"; readonly items: readonly Item[]; readonly paidAt: Date; readonly amount: number }
| { readonly kind: "shipped"; readonly trackingId: string; readonly shippedAt: Date }
| { readonly kind: "delivered"; readonly deliveredAt: Date }
| { readonly kind: "cancelled"; readonly reason: string };
// Each state has EXACTLY the fields it needs.
// A draft has no submittedAt. A shipped order has a trackingId.
// Accessing trackingId on a draft is a compile error.
function nextActions(order: OrderState): string[] {
switch (order.kind) {
case "draft": return ["submit", "add_item", "remove_item", "cancel"];
case "submitted": return ["pay", "cancel"];
case "paid": return ["ship", "refund"];
case "shipped": return ["mark_delivered"];
case "delivered": return ["return"];
case "cancelled": return [];
}
}
// Add a new state → compile error. You WILL handle it.Check Your Understanding
Why is null called the 'billion-dollar mistake'?
When should you use Result instead of throwing an exception?
What does the map combinator do on an Option?
Practice
Implement Option and Result from scratch, then use them to eliminate null and exceptions:
Summary
- Null is ambiguous. It conflates "not found," "error," "not yet loaded," and "empty" into one value.
- Option<T> explicitly represents presence (
some) or absence (none). The type system forces callers to check. - Result<T, E> explicitly represents success (
ok) or failure (err). Errors are values with typed reasons, not invisible exceptions. - Combinators (
map,flatMap,unwrapOr) let you chain operations without nesting. - Use exceptions for bugs (unexpected failures). Use Result for expected failures (validation, not found, permission denied).
- Exhaustive pattern matching on discriminated unions turns forgotten cases into compile errors.
Next, we explore composition: building complex behavior from simple, independent pieces.