Mastering TypeScript: Advanced Types and Patterns
TypeScript's type system is a language of its own. Used well, it turns whole classes of runtime bugs into red squiggles. The jump from good to great is learning to make illegal states unrepresentable.
Discriminated Unions
Model state as a union with a literal discriminant and the compiler forces you to handle every case — no more "loading is true but there is also an error" limbo:
type RequestState<T> = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: string }; function render(state: RequestState<User[]>) { switch (state.status) { case 'success': return <List users={state.data} />; // data exists here case 'error': return <Alert msg={state.error} />; // error exists here default: return <Spinner />; }}Utility and Mapped Types
Derive types instead of duplicating them. When the source type changes, everything downstream updates for free:
interface User { id: string; name: string; email: string; role: Role } type UserPreview = Pick<User, 'id' | 'name'>;type UserUpdate = Partial<Omit<User, "id">>;type UsersById = Record<string, User>;Type Safety at the Boundaries
Types vanish at runtime, and the outside world doesn't read your interfaces. API responses, form input, and environment variables all need runtime validation — and with a schema library like Zod you validate once and infer the static type from the same source of truth:
import { z } from 'zod'; const UserSchema = z.object({ id: z.string().uuid(), name: z.string().min(1), email: z.string().email(),}); type User = z.infer<typeof UserSchema>; // static type, for freeconst user = UserSchema.parse(await res.json()); // runtime guaranteeReach for generics when behavior repeats across types, const assertions for exact literals, and satisfies to validate without widening. The goal is always the same: let the compiler carry the invariants so your tests can focus on behavior.