TypeScript Patterns That Actually Make Your Code Better
Beyond Basic Types — The Patterns Senior Developers Use to Write TypeScript That's Safe, Readable, and Refactor-Proof
Senior Developer

Stop Using Types Just for Autocomplete
If you're using TypeScript mainly so your editor can suggest method names, you're getting maybe 10% of the value. TypeScript's real power is making impossible states impossible to represent — so whole categories of bugs can't exist in your codebase.
Consider a function that fetches a user:
// 😐 Typed, but still fragile
function getUser(id: string): User | null {
// ...
}
const user = getUser("42");
console.log(user.name); // Runtime crash if user is null — TypeScript won't catch thisTypeScript lets you be far more expressive than this. The patterns below show you how to use the type system to its full potential.
Discriminated Unions: Model Your States Honestly
A discriminated union is a type that has a common "tag" field — a literal type that tells you which variant you're looking at. It's one of the most useful patterns in TypeScript and it shows up everywhere once you know it.
The classic example is an API response state:
// 😐 The naive approach — fields that may or may not exist
type ApiState = {
loading: boolean;
data?: User;
error?: string;
};
// This is technically valid — but it represents nonsense
const state: ApiState = { loading: false, data: user, error: "failed" };With a discriminated union, impossible combinations simply don't type-check:
// ✅ Each state is modeled as its own type
type ApiState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; message: string };Now when you handle this state, TypeScript narrows it precisely:
function render(state: ApiState) {
switch (state.status) {
case "idle":
return <EmptyState />;
case "loading":
return <Spinner />;
case "success":
return <UserCard user={state.data} />; // TypeScript knows data exists here
case "error":
return <ErrorMessage text={state.message} />; // And message exists here
}
}If you add a new status variant to ApiState later, TypeScript will warn you about every switch and if block that doesn't handle it. That's a compile-time refactoring safety net that's genuinely valuable in large codebases.
Exhaustive Checks: Never Miss a Case
When you have a discriminated union and a switch statement, TypeScript can guarantee you've handled every case — but only if you ask it to. Here's the pattern:
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
function render(state: ApiState) {
switch (state.status) {
case "idle": return <EmptyState />;
case "loading": return <Spinner />;
case "success": return <UserCard user={state.data} />;
case "error": return <ErrorMessage text={state.message} />;
default: return assertNever(state); // ← TypeScript error if any case is unhandled
}
}If ApiState gains a new variant ("stale", say) and you forget to handle it in render, TypeScript flags the assertNever(state) call because state is no longer of type never in the default branch. The compiler tells you exactly which function needs updating — before the bug reaches a user.
The satisfies Operator: Validate Without Widening
Introduced in TypeScript 4.9, satisfies is one of those additions that solves a real frustration you might not have known how to articulate.
The problem it solves: when you annotate a variable with a type, TypeScript widens it to that type. You lose the more specific information TypeScript inferred.
type Routes = Record<string, { path: string; auth: boolean }>;
// 😐 With a type annotation — TypeScript sees it as Record<string, ...>
const routes: Routes = {
home: { path: "/", auth: false },
dashboard: { path: "/dashboard", auth: true },
};
routes.home; // ✅ Works
routes.settings; // ✅ Also "works" — but this key doesn't exist!With satisfies, the type is validated but the more specific inferred type is preserved:
// ✅ With satisfies — validated AND specific
const routes = {
home: { path: "/", auth: false },
dashboard: { path: "/dashboard", auth: true },
} satisfies Routes;
routes.home; // ✅ Works
routes.settings; // ❌ TypeScript error — key doesn't existAnother great use case: palette objects where you want both validation and key autocompletion:
type Color = "red" | "green" | "blue";
type Palette = Record<Color, string>;
const palette = {
red: "#ef4444",
green: "#22c55e",
blue: "#3b82f6",
} satisfies Palette;
palette.red; // ✅ Type is string, not widened to Record<Color, string>
palette.purple; // ❌ Compile errorBranded Types: Prevent Primitive Mix-ups
TypeScript's structural type system means that string and string are always compatible, even when they represent very different things:
function transferMoney(fromAccountId: string, toAccountId: string, amount: number) {
// ...
}
// TypeScript is fine with this — but it's a bug
transferMoney(toAccountId, fromAccountId, amount);Branded types (also called opaque types or nominal types) make primitives incompatible with each other even when they're the same underlying type:
type AccountId = string & { readonly __brand: "AccountId" };
type UserId = string & { readonly __brand: "UserId" };
type Dollars = number & { readonly __brand: "Dollars" };
// Constructor functions that "create" branded values
function toAccountId(id: string): AccountId {
return id as AccountId;
}
function toUserId(id: string): UserId {
return id as UserId;
}
function transferMoney(from: AccountId, to: AccountId, amount: Dollars) {
// ...
}
const accountId = toAccountId("acc_123");
const userId = toUserId("usr_456");
transferMoney(userId, accountId, 100 as Dollars); // ❌ TypeScript error — UserId is not AccountId
transferMoney(accountId, accountId, 100 as Dollars); // ✅The __brand field only exists in the type system — it has no runtime cost. At runtime these are still plain strings and numbers. But at compile time, TypeScript treats them as incompatible. You can't accidentally swap IDs of different entity types.
This is especially valuable in financial code, user permissions, and anywhere primitives have important semantic distinctions.
Type Guards: Teach TypeScript What You Know
Sometimes TypeScript can't narrow a type on its own — you have information that the type system doesn't. Type guards let you encode that knowledge in a reusable, type-safe way.
A simple typeof check is already a type guard — TypeScript understands it. But for custom types you need to write your own:
type Cat = { kind: "cat"; meow: () => void };
type Dog = { kind: "dog"; bark: () => void };
type Pet = Cat | Dog;
// Type guard — the return type `pet is Cat` is the magic
function isCat(pet: Pet): pet is Cat {
return pet.kind === "cat";
}
function makeNoise(pet: Pet) {
if (isCat(pet)) {
pet.meow(); // ✅ TypeScript knows pet is Cat here
} else {
pet.bark(); // ✅ TypeScript knows pet is Dog here
}
}A more practical example — validating API responses:
type ApiUser = {
id: number;
name: string;
email: string;
};
function isApiUser(value: unknown): value is ApiUser {
return (
typeof value === "object" &&
value !== null &&
typeof (value as ApiUser).id === "number" &&
typeof (value as ApiUser).name === "string" &&
typeof (value as ApiUser).email === "string"
);
}
async function fetchUser(id: number): Promise<ApiUser> {
const res = await fetch(`/api/users/${id}`);
const data: unknown = await res.json();
if (!isApiUser(data)) {
throw new Error("Unexpected response shape from /api/users");
}
return data; // ✅ TypeScript is now confident this is ApiUser
}For more complex validation, libraries like zod generate type guards automatically from schema definitions — which is cleaner than writing them by hand for every type.
Mapped Types: Transform Types Programmatically
Mapped types let you create new types by transforming every property of an existing type. TypeScript's built-in utilities (Partial, Required, Readonly, Pick, Omit) are all implemented as mapped types.
Writing your own unlocks powerful patterns:
// Make all properties optional and nullable
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
// Make all functions in an object async
type Asyncify<T> = {
[K in keyof T]: T[K] extends (...args: infer A) => infer R
? (...args: A) => Promise<R>
: T[K];
};
// Create a type with only the methods of an object (no data properties)
type MethodsOnly<T> = {
[K in keyof T as T[K] extends Function ? K : never]: T[K];
};A very practical use case — creating an "update" type for a database entity where every field is optional except the ID:
type User = {
id: number;
name: string;
email: string;
role: "admin" | "member";
};
type UserUpdate = { id: number } & Partial<Omit<User, "id">>;
// → { id: number; name?: string; email?: string; role?: "admin" | "member" }
function updateUser(update: UserUpdate) {
// id is always required, everything else is optional
}Template Literal Types: Type-Level String Manipulation
Template literal types let you build string types from other types — which is useful for event names, CSS properties, API routes, and more.
type EventName = "click" | "focus" | "blur";
type EventHandler = `on${Capitalize<EventName>}`;
// → "onClick" | "onFocus" | "onBlur"
type CSSProperty = "margin" | "padding";
type CSSDirection = "Top" | "Right" | "Bottom" | "Left";
type CSSSpacingProperty = `${CSSProperty}${CSSDirection}`;
// → "marginTop" | "marginRight" | ... | "paddingBottom" | "paddingLeft"A practical example — type-safe event emitters:
type UserEvents = {
"user:created": { id: number; name: string };
"user:deleted": { id: number };
"user:updated": { id: number; changes: Partial<User> };
};
type EventKey = keyof UserEvents;
function emit<K extends EventKey>(event: K, payload: UserEvents[K]) {
// ...
}
emit("user:created", { id: 1, name: "Jane" }); // ✅
emit("user:deleted", { id: 1, name: "Jane" }); // ❌ name shouldn't be here
emit("user:created", { id: "1", name: "Jane" }); // ❌ id must be a numberconst Assertions: Lock Down Literal Types
By default, TypeScript infers a mutable, widened type for object and array literals. as const tells it to infer the narrowest possible type and make everything readonly:
// Without as const
const config = {
env: "production",
retries: 3,
};
// config.env is string, config.retries is number
// With as const
const config = {
env: "production",
retries: 3,
} as const;
// config.env is "production" (literal), config.retries is 3 (literal)
// Everything is readonlyThis is particularly useful for lookup objects and configuration maps where you want TypeScript to know the exact values:
const HTTP_STATUS = {
OK: 200,
CREATED: 201,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
NOT_FOUND: 404,
} as const;
type StatusCode = typeof HTTP_STATUS[keyof typeof HTTP_STATUS];
// → 200 | 201 | 400 | 401 | 404
function setStatus(code: StatusCode) { ... }
setStatus(HTTP_STATUS.OK); // ✅
setStatus(200); // ✅
setStatus(999); // ❌ TypeScript errorAvoid any — Use unknown Instead
When you reach for any because you don't know a type, use unknown instead. The difference is critical:
anydisables type checking entirely for that value — you can do anything with it and TypeScript won't complainunknownforces you to narrow the type before using it — TypeScript requires proof that you know what you're working with
// 😐 any — TypeScript looks away
function parseConfig(input: any) {
return input.settings.theme; // No error, even if this explodes at runtime
}
// ✅ unknown — TypeScript makes you prove the shape
function parseConfig(input: unknown) {
if (
typeof input === "object" &&
input !== null &&
"settings" in input
) {
// Now TypeScript allows accessing input.settings
}
}In practice: use unknown for external data (API responses, parsed JSON, user input), use any only as a last resort in genuinely untyped interop scenarios, and enable noImplicitAny in your tsconfig.json to ban accidental any from untyped parameters.
Summary
TypeScript's type system is expressive enough to model nearly any domain concept accurately. Discriminated unions eliminate impossible states. Branded types prevent primitive mix-ups. satisfies validates without losing specificity. Type guards make narrowing reusable and expressive. unknown forces safe handling of external data.
The payoff for investing in these patterns is code where the types tell a story about the domain — where reading a function signature tells you not just what types are involved, but what states are possible and what the function expects from its inputs. That's code that's genuinely easier to maintain, refactor, and reason about.
Comments (0)
Login to post a comment.