Algebraic data types: Option and Result as alternatives to null and exceptions
Go's own domain covered an entire language built around explicit (result, error) returns instead of exceptions. Functional languages solve the exact same "might fail, might be absent" problem with a different, more structurally enforced tool — and understanding it makes Go's own choice, and a surprising amount of ordinary Python/TypeScript code, look like specific instances of one general idea.
Advanced
3 min read
The problem both null and exceptions share: nothing forces you to check
def find_user(user_id): return db.query(user_id) # returns a User, or None if not founduser = find_user(42)print(user.name) # CRASHES if user is None — nothing forced a check first
Returning None to represent "not found" is genuinely useful, but the type system (where one exists) doesn't distinguish "definitely a User" from "a User, or possibly None" — user.name compiles and runs identically whether find_user actually found someone or not, and the crash only happens the moment it's finally called on a real, missing case. This is the exact gap Tony Hoare, who invented the null reference, later called his "billion-dollar mistake."
Option/Maybe: making "might be absent" visible in the type itself
# A simplified Option typeclass Some: def __init__(self, value): self.value = valueclass NoneOption: passdef find_user(user_id) -> "Some | NoneOption": result = db.query(user_id) return Some(result) if result else NoneOption()user_option = find_user(42)match user_option: case Some(user): print(user.name) # only reachable if a user actually exists case NoneOption(): print("not found") # the ABSENCE case is handled explicitly, every time
An Option (called Maybe in Haskell, Option in Rust and Scala) wraps a value in one of exactly two shapes: Some(value) (something is there) or None/Nothing (nothing is there) — and critically, a caller cannot access the inner value without first handling both cases, because the wrapped type and the bare value are structurally different types the compiler distinguishes. This converts "forgetting to check for absence" from a runtime crash waiting to happen into a compile-time error — there is no .value to accidentally call on the wrapper type itself.
Result/Either: the same idea, applied to "might fail" instead of "might be absent"
class Ok: def __init__(self, value): self.value = valueclass Err: def __init__(self, error): self.error = errordef parse_age(s) -> "Ok | Err": try: return Ok(int(s)) except ValueError as e: return Err(str(e))match parse_age("abc"): case Ok(age): print(age) case Err(msg): print("failed:", msg)
Result (called Either in Haskell and Scala) is Option's sibling for failure rather than absence: Ok(value) or Err(error), forcing the same explicit-handling discipline on the failure case that Option forces on the absence case. This is structurally the exact same idea as Go's (result, error) return-value pattern (Go's own error-handling lesson) — both exist specifically to make "this might not have worked" visible in a function's own signature/type, rather than leaving it to an exception that might be thrown from anywhere, silently, unless a reader already knows to expect it.
Where this pattern already exists, quietly, in languages without formal ADTs
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
TypeScript doesn't have built-in algebraic data types, but its discriminated unions (a structural typing pattern this curriculum's own TypeScript domain covered) can express the exact same Result shape by hand — and several real, widely-used libraries do exactly this deliberately, specifically to get this same "can't forget to check" guarantee in a language that doesn't enforce it natively the way Haskell or Rust does.