Appearance
Unions & narrowing - UI state without the soup
"This value is one of several shapes." That's most of your loading screens, button variants, and API results.
Unions model "this value is one of several shapes." Narrowing is how you teach TypeScript which shape you're currently in. Critical for UI state and variants.
Basic unions
ts
type ButtonVariant = 'primary' | 'secondary' | 'ghost'
type Id = string | number
function formatId(id: Id): string {
if (typeof id === 'number') {
return String(id) // narrowed to number
}
return id // narrowed to string
}Discriminated unions for UI state
This pattern shows up constantly in interviews and in real apps (loading screens, query results, remote data):
ts
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string }
function renderUser(state: AsyncState<{ name: string }>): string {
switch (state.status) {
case 'idle':
return 'Pick a user'
case 'loading':
return 'Loading…'
case 'success':
return state.data.name // OK: data exists
case 'error':
return state.error // OK: error exists
}
}Comments for the code above:
- the discriminant is
status - once you check it, TypeScript knows which fields exist
- this beats optional soup every day of the week
ts
// Harder to keep consistent
type MessyState<T> = {
loading: boolean
data?: T
error?: string
}Narrowing with in and equality
ts
type Success = { ok: true; value: string }
type Failure = { ok: false; message: string }
type Result = Success | Failure
function message(result: Result): string {
if (result.ok) return result.value
return result.message
}
type Circle = { kind: 'circle'; radius: number }
type Rect = { kind: 'rect'; width: number; height: number }
function area(shape: Circle | Rect): number {
if ('radius' in shape) return Math.PI * shape.radius ** 2
return shape.width * shape.height
}Button / component variants
ts
type IconButton = {
kind: 'icon'
icon: string
label: string // a11y
}
type TextButton = {
kind: 'text'
label: string
}
type ButtonProps = IconButton | TextButton
function buttonLabel(props: ButtonProps): string {
return props.kind === 'icon' ? props.label : props.label
}Common pitfall
Forgetting a discriminant and using overlapping optionals - TypeScript cannot prove when data is defined:
ts
if (!state.loading && state.data) {
// still easy to leave error + data both set
}Use a tagged union instead.
ts
type State<T> =
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string }
function readData<T>(state: State<T>): T | string {
if (state.status === 'success') {
return state.data // data is definitely defined
}
if (state.status === 'error') {
return state.error
}
return 'still loading'
}As you can see - once status is 'success', data is just there. No optional gymnastics.
Mini check
Question: Rewrite { loading: boolean; user?: User; error?: string } as a discriminated union.
Answer
ts
type User = { id: string; name: string }
type UserState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; user: User }
| { status: 'error'; error: string }Next: Interfaces vs types.