Practical TypeScript patterns that have genuinely improved the quality and maintainability of my codebases — beyond the basics you already know.
Most TypeScript tutorials cover interface vs type and basic generics. This isn't that article. These are the patterns I reach for on real projects.
Optional fields are a code smell disguised as convenience. When you see this:
type Response = {
data?: User;
error?: string;
loading?: boolean;
};You get into a situation where data and error can both be defined at the same time, which is nonsensical. A discriminated union is explicit about what states are actually possible:
type Response =
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string };Now TypeScript narrows correctly in switch statements, and impossible states are literally unrepresentable.
satisfies for config objectsThe satisfies operator (introduced in TS 4.9) is underused. It validates that a value matches a type, but preserves the literal type rather than widening it.
const config = {
theme: "dark",
lang: "en",
} satisfies Record<string, string>;
// config.theme is "dark" (literal), not string
// without satisfies, you'd need `as const` and lose the type checkI use this constantly for route configs, theme objects, and feature flag maps.
String-based APIs are bug magnets. Template literal types give you full autocomplete and type checking on string values:
type Direction = "top" | "right" | "bottom" | "left";
type Padding = `p${Capitalize<Direction>}`;
// "pTop" | "pRight" | "pBottom" | "pLeft"
type SpacingKey = `spacing-${1 | 2 | 4 | 8 | 16}`;
// "spacing-1" | "spacing-2" | "spacing-4" | "spacing-8" | "spacing-16"This is especially powerful for design token systems and CSS-in-JS utilities.
TypeScript's structural typing means UserId and PostId — both strings — are interchangeable. That's a bug waiting to happen.
type Brand<T, B> = T & { readonly _brand: B };
type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;
function getUser(id: UserId) { /* ... */ }
const postId = "post_123" as PostId;
getUser(postId); // ❌ Type error — exactly what we wantThe runtime representation is still just a string. Zero cost, maximum safety.
infer in conditional typesOnce it clicks, infer unlocks a whole class of utility types:
// Extract the resolved type of a Promise
type Awaited<T> = T extends Promise<infer R> ? R : T;
// Extract function return type (without using ReturnType<>)
type Return<T> = T extends (...args: any[]) => infer R ? R : never;
// Extract array element type
type Item<T> = T extends (infer E)[] ? E : never;These compose. Need the return type of an async function?
type AsyncReturn<T> = Awaited<Return<T>>;anyany isn't always wrong. A // @ts-ignore or as any in a third-party integration, a genuinely dynamic runtime value, or a migration boundary is fine. What's not fine is using any because you haven't thought through the types yet. The discipline is in being intentional — use unknown as the default unknown type, and cast only when you've validated the shape.
TypeScript's type system is a tool for encoding invariants. The patterns above aren't clever tricks — they're ways to make the type system reflect reality more precisely, so the compiler catches real bugs instead of just inferring any.