TypeScript gets sold as "JavaScript with types." That framing undersells it. When used properly, it catches entire categories of runtime errors at compile time and makes large refactors survivable. Here are the patterns that have mattered most in production.
Discriminated Unions for State Machines
Representing async state as four separate booleans (isLoading, isError, isSuccess, data) creates impossible states. You can have isLoading: true and isSuccess: true simultaneously, which should never happen.
Discriminated unions model this correctly:
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'success'; data: T };
// TypeScript enforces correct access — no runtime surprises
function render(state: FetchState<User>) {
if (state.status === 'success') {
return state.data.username; // TypeScript knows data exists here
}
}Branded Types for Domain Values
When you have multiple string or number values that should not be interchangeable (user IDs, session tokens, pad slugs), plain string type allows accidental mixing. Branded types prevent this:
type UserId = string & { readonly brand: 'UserId' };
type PadSlug = string & { readonly brand: 'PadSlug' };
function getUser(id: UserId) { /* ... */ }
const slug = 'my-pad' as PadSlug;
getUser(slug); // TypeScript error — correct!Exhaustive Switch Checking
When you add a new variant to a union type, TypeScript can force you to handle it everywhere it is used:
function assertNever(x: never): never {
throw new Error('Unhandled case: ' + JSON.stringify(x));
}
type Status = 'active' | 'inactive' | 'suspended';
function handleStatus(s: Status) {
switch (s) {
case 'active': return 'green';
case 'inactive': return 'gray';
case 'suspended': return 'red';
default: return assertNever(s); // Compile error if Status grows
}
}Add 'banned' to the Status type and TypeScript immediately tells you this function does not handle it — before a user sees undefined behavior at runtime.