Part 3 – Unions & Discriminated Unions
This is a series of posts I’m writing about using types as another tool in software development, Continuous Delivery, & keeping LLM’s honest. They’re also a design & refactoring tool, a communication tool, and reduce how many tests you have to write.
Parts:
- Part 1 – Branded Types
- Part 2 – Product Types
- Part 3 – Union & Discriminated Unions
- Part 4 – Non-Empty Collections
- Part 5 – Indexed Types
- Part 6 – unknown vs any
- Part 7 – Result
- Part 8 – Schema
- Part 9 – Total Function
- Part 10 – Errors as Values
- Part 11 – Property Tests
- Part 12 – Type Proofs
- Part 13 – Exhaustiveness Checking
- Part 14 – Parse, Don’t Validate
- Part 15 – Anti-Corruption Layer
- Part 16 – Opaque Types
- Part 17 – Maybe
- Part 18 – Smart Constructors
- Part 19 – Pipeline
- Part 20 – Railway Oriented Programming
- Part 21 – Typestate
Branded types allow us to differentiate between things & Product Types what things belong together. Unions/Discriminated Unions help us narrow things, identifying “what things are allowed?”


type AccountType = string means “Primary”, “Secondary”, and “Authorized” is fine, but so is “moo”, “🐄”, & “”. That doesn’t restrict things enough. If our API’s only support those 3 account types, then we need a Union:
type AccountType = "Primary" | "Secondary" | "Authorized"Code language: JavaScript (javascript)
Unions come w/Exhaustiveness Checking so you no longer need to write a default case when you pattern match on what the type is:
switch(accountType) {
case "Primary": …
case "Secondary": …
case "Authorized": …
}Code language: JavaScript (javascript)
The compiler ensures you handled all 3, default isn’t needed, & later when you add or change one, the compiler ensures all places that inspect the AccountType are updated. Useful for both encoding business rules, replacing magic strings w/”only these strings”, & help you have a place for additional domain discovery.
Discriminated Unions are often used to make Impossible Situations Impossible. In modelling state in UI’s you may have:
type Request = {
loading: boolean
data?: User
error?: string
}
But states like this are possible & you must write unit tests to ensure they’re not & hope you don’t miss one:
{
loading: true,
data: user,
error: "failed"
}Code language: CSS (css)
Instead, you can ensure those states can’t possibly happen, AND those states can’t access data they’re not allowed to:
type Request =
| { tag: "loading" }
| { tag: "success"; data: User }
| { tag: "error"; error: string }Code language: JavaScript (javascript)
Only the error state can access the error, only the success state can access data. If you mess it up in switch or if statements while type narrowing, the compiler will tell you and won’t compile the code:
switch(request.tag) {
…
case "success":
return <Error data={request . error} />
// ❌ fails to compile, error does not exist on Success
case "error":
return <Success data={request . data} />
// ❌ fails to compile, data does not exist on Error
switch(request.tag) {
…
case "success":
return <Success data={request . data} /> // ✅ works
case "error":
return <Error data={request . error} /> // ✅ worksCode language: JavaScript (javascript)
They’re also great for replacing Maybe’s (a nicer null). Instead of:
User | null
We can use:
type UserLookup =
| Found
| NotFound
type Found = { tag: 'found'; user: User }
type NotFound = { tag: 'not-found' }Code language: JavaScript (javascript)
Unions determine what is allowed & Discriminated Unions enhance that by defining what data is allowed in each alternative. Product Types are often called AND types like “this and that” while Unions are called OR types like “this or that”.
Leave a Reply