Concept · Building apps
Narrowing and discriminated unions
Let TypeScript prove which case you’re in from a check you already wrote, and catch the case you forgot.
A union type such as string | number or User | null says "one of these." Narrowing is how TypeScript works out which one from the checks in your code. Inside the if, the variable has the narrower type.
function format(value: string | number | null) {
if (value === null) return '—' // value: null
if (typeof value === 'string') return value.trim() // value: string
return value.toFixed(2) // value: number — the only case left
}TypeScript understands typeof, instanceof, in, equality checks, truthiness, Array.isArray, and early return/throw.
Discriminated unions
Give every variant a shared literal field, the discriminant, and a single check narrows the whole object:
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: Order[] }
| { status: 'error'; error: string }
function render(s: RequestState) {
switch (s.status) {
case 'idle': return null
case 'loading': return <Spinner />
case 'success': return <OrderList orders={s.data} /> // data exists only here
case 'error': return <Alert>{s.error}</Alert>
}
}This makes impossible states impossible. With separate isLoading, data and error fields you can end up with isLoading: true and an error. With a union you can't.
Exhaustiveness checks
Make the compiler tell you when you add a variant and forget to handle it:
default: {
const unreachable: never = s // error if any status isn't handled above
throw new Error(`unhandled: ${JSON.stringify(unreachable)}`)
}Custom type guards and their limit
function isOrder(x: unknown): x is Order {
return typeof x === 'object' && x !== null && 'id' in x && 'total' in x
}A type guard is a promise you make to the compiler, and TypeScript doesn't check it. For data from outside your program, such as API responses, localStorage or model output, validate at runtime with a schema library like Zod, and let it produce the narrowed type.