Modern TypeScript Patterns I Use Every Day
Practical TypeScript patterns that improve code quality and developer experience in real projects.
Every time I mass-rename a field across a codebase, zero tests break. Not because the tests are bad, but because TypeScript catches every callsite at compile time. That reliability is why I keep investing in stricter typing. The payoff is silent, but it shows up exactly when you need it.
These are the patterns I reach for most often. Some are old favourites, some landed in recent releases, and a few took me an embarrassingly long time to start using.
Discriminated Unions for State
Instead of optional properties scattered everywhere, use discriminated unions:
// Avoid this
type ApiState = {
loading?: boolean;
data?: User[];
error?: Error;
};
// Prefer this
type ApiState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: Error };
This makes impossible states unrepresentable and gives you exhaustive checking.
The satisfies Operator
Before satisfies landed in TypeScript 4.9, I had an annoying choice: annotate the type and lose narrow inference, or skip the annotation and lose validation. satisfies removes that trade-off. It validates the shape while keeping the inferred type intact:
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
} satisfies Record<string, string | number>;
// config.timeout is still inferred as number, not string | number
Branded Types for Safety
I once spent an afternoon debugging a function that received an OrderId where it expected a UserId. Both were plain strings, so TypeScript had nothing to complain about. Branded types fix this by making structurally identical primitives nominally distinct:
type UserId = string & { readonly brand: unique symbol };
type OrderId = string & { readonly brand: unique symbol };
function getUser(id: UserId) { /* ... */ }
const userId = "123" as UserId;
const orderId = "456" as OrderId;
getUser(userId); // OK
getUser(orderId); // Error!
Const Assertions
This one is deceptively simple, but I use it constantly. Without as const, TypeScript widens your object values to their base types. With it, you get exact literal types, which means you can derive union types directly from your data:
const ROUTES = {
home: "/",
blog: "/blog",
about: "/about",
} as const;
type Route = (typeof ROUTES)[keyof typeof ROUTES];
// Route = "/" | "/blog" | "/about"
Generic Constraints
Sometimes you need a generic function but want to guarantee the type has certain properties. That’s where constraints come in:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30 };
getProperty(user, "name"); // OK, returns string
getProperty(user, "email"); // Error: "email" doesn't exist on user
The compiler catches the typo before you ever run the code.
Utility Types
I used to write variations of the same type over and over. Now I just compose the built-in utilities:
type User = {
id: string;
name: string;
email: string;
createdAt: Date;
};
// For updates, make all fields optional except id
type UpdateUser = Pick<User, "id"> & Partial<Omit<User, "id">>;
// For creation, omit server-generated fields
type CreateUser = Omit<User, "id" | "createdAt">;
One source of truth, multiple derived types. When User changes, everything stays in sync.
Conditional Types
Conditional types were the last piece of TypeScript’s type system that clicked for me. But once I needed an API helper whose return type depended on whether the caller passed data, I was sold. The idea is simple: the type branches based on what you give it:
type ApiResponse<T> = T extends undefined
? { success: boolean }
: { success: boolean; data: T };
function respond<T>(data?: T): ApiResponse<T> {
return data !== undefined
? { success: true, data }
: { success: true };
}
respond(); // { success: boolean }
respond({ id: 1 }); // { success: boolean; data: { id: number } }
The return type changes based on what you pass in. No more data: T | undefined everywhere.
Mapped Types
Ever needed to transform every property of a type in the same way? Mapped types let you iterate over keys and build new types:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }
I reach for this when building APIs that need consistent getter/setter patterns or when wrapping types for different contexts.
Conclusion
Individually, none of these patterns feel groundbreaking. The real shift happens when they start reinforcing each other. Branded types feed into discriminated unions, const assertions power mapped types, and utility types keep everything DRY. Over time, the compiler stops being a thing you wrestle with and starts acting like a second pair of eyes that never gets tired.
If you only take one thing away, make impossible states unrepresentable. Every pattern above is, in some way, a variation on that idea.