When I started using TypeScript I treated it like
JavaScript with one extra step — add a type here,
add an interface there, satisfy the compiler and
move on.
That approach works until it doesn't. You end up
with types that are technically correct but don't
actually prevent the bugs TypeScript is supposed to
prevent.
These are the features that changed how I use it.
Discriminated unions — for state that has multiple modes
I used to model loading states like this:
interface UserState {
user: User | null
loading: boolean
error: string | null
}
The problem: this allows combinations that should be
impossible. loading: true and error: "something" at
the same time. user: someUser and loading: true
simultaneously.
TypeScript won't catch these. The types technically
allow them.
Discriminated unions model the actual possible states:
type UserState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; user: User }
| { status: 'error'; error: string }
Now TypeScript knows that when status is 'success',
user exists and error doesn't. When status is 'error',
error exists and user doesn't.
const renderUser = (state: UserState) => {
switch (state.status) {
case 'loading':
return <Spinner />
case 'success':
return <Profile user={state.user} /> // user is User here, not User | null
case 'error':
return <Error message={state.error} /> // error is string here, not string | null
case 'idle':
return null
}
}
The compiler knows exactly what's available in each
branch. No null checks, no "this should never happen"
comments.
Const assertions — for values that never change
I was defining arrays of options like this:
const DIRECTIONS = ['north', 'south', 'east', 'west']
TypeScript infers the type as string[]. If I try to
use a value from this array somewhere that expects
a specific direction type, it fails — even though
I know the values are exactly those four strings.
Const assertion fixes this:
const DIRECTIONS = ['north', 'south', 'east', 'west'] as const
// Type: readonly ["north", "south", "east", "west"]
type Direction = typeof DIRECTIONS[number]
// Type: "north" | "south" | "east" | "west"
Now Direction is a proper union type derived from the
array. If I add a fifth direction to the array, the
type updates automatically. One source of truth.
I use this constantly for:
- Navigation link arrays
- Status values
- Configuration options
- Any array that's treated as an enum
Template literal types — for string patterns
I needed a type for CSS color values — specifically
hex colors like #fff or #ffffff.
Before I knew about template literal types I either
used string (too broad) or wrote a runtime validation
function (works but TypeScript doesn't know about it
at compile time).
type HexColor = `#${string}`
const setColor = (color: HexColor) => {
document.body.style.backgroundColor = color
}
setColor('#ff0000') // fine
setColor('red') // TypeScript error — not a HexColor
setColor('#') // technically passes — TypeScript can't
// validate the full pattern, only the prefix
It's not perfect — TypeScript can't validate that the
characters after # are valid hex. But it catches the
obvious wrong inputs at compile time without any
runtime cost.
More useful examples:
// Event names that follow a pattern
type EventName = `on${Capitalize<string>}`
// Matches: onClick, onChange, onSubmit
// API endpoint patterns
type ApiRoute = `/api/${string}`
// Matches: /api/users, /api/products
// CSS size values
type Size = `${number}px` | `${number}rem` | `${number}%`
// Matches: 16px, 1.5rem, 100%
Mapped types — for transforming interfaces
I needed a "partial update" version of my User interface —
same fields but all optional, for PATCH requests.
My first approach: copy the interface and add ? to everything.
interface User {
id: string
name: string
email: string
role: 'admin' | 'user'
}
interface UserUpdate {
id?: string
name?: string
email?: string
role?: 'admin' | 'user'
}
This is a maintenance problem. Every time User changes,
UserUpdate needs to change too. You will forget. The
types will drift.
Mapped types solve this:
type UserUpdate = Partial<User>
// Exactly the same as writing all the optional fields manually
// But it updates automatically when User changes
TypeScript has several built-in mapped types worth knowing:
Partial<T> // All fields optional
Required<T> // All fields required
Readonly<T> // All fields readonly
Pick<T, K> // Only the fields you name
Omit<T, K> // All fields except the ones you name
Real example:
interface User {
id: string
name: string
email: string
password: string
createdAt: Date
}
// Safe to send to the client — no password, no internal fields
type PublicUser = Omit<User, 'password' | 'createdAt'>
// For update requests — everything optional except id
type UpdateUserRequest = Required<Pick<User, 'id'>> &
Partial<Omit<User, 'id' | 'createdAt'>>
The satisfies operator — new and genuinely useful
Added in TypeScript 4.9. I missed it for a long time
and when I found it I used it immediately.
The problem it solves: you want TypeScript to check that
a value matches a type, but you also want TypeScript to
remember the exact shape of the value (not just the type).
type Colors = {
[key: string]: string | { light: string; dark: string }
}
// With a type annotation
const palette: Colors = {
red: '#ff0000',
blue: { light: '#add8e6', dark: '#00008b' }
}
// TypeScript thinks palette.red is string | { light: string; dark: string }
// even though we know it's just a string
palette.red.toUpperCase() // Error — might not be a string
With satisfies:
const palette = {
red: '#ff0000',
blue: { light: '#add8e6', dark: '#00008b' }
} satisfies Colors
// TypeScript remembers the exact types
palette.red.toUpperCase() // Fine — TypeScript knows red is a string
palette.blue.light // Fine — TypeScript knows blue has light and dark
You get the type checking of an annotation without losing
the precision of inference. Best of both.
One thing I still get wrong
Generic constraints. I understand them, I write them,
I still occasionally write one that's more restrictive
than it needs to be and blocks a valid use case.
// Too restrictive
const getProperty = <T extends object, K extends keyof T>(
obj: T,
key: K
): T[K] => obj[key]
// I've written constraints like this when the function
// worked fine with a simpler signature
If you find yourself fighting TypeScript's type checker
on something that should obviously work — check your
constraints first. They might be the problem.
TypeScript's value is in the bugs you never write, not
just the ones it catches. The types above moved me from
"TypeScript stops the obvious mistakes" to "TypeScript
makes impossible states actually impossible."
That's the version of TypeScript worth learning.
Top comments (0)