12 Days of TypeScript: Patterns Worth Keeping
Cyber Monday deals are done. Here's something actually useful: the TypeScript patterns I reach for on every project that consistently reduce bugs without overengineering.
TypeScript's value is in the feedback loop, not the type annotations. Here are the patterns that actually earn their keep on production projects.
Strict mode, always
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}
noUncheckedIndexedAccess is not included in strict but should be. It makes array and record indexing return T | undefined instead of T, forcing you to handle the case where the element doesn't exist. You'll fix several real bugs the first time you turn it on.
Branded types for IDs
type UserId = string & { readonly __brand: "UserId" };
type PostId = string & { readonly __brand: "PostId" };
function getUser(id: UserId) { ... }
Passing a PostId to getUser is now a type error. This sounds like overkill until you've shipped a bug where you passed the wrong ID to the wrong function. Takes five minutes to set up per entity type.
Discriminated unions over optional fields
Instead of:
type ApiResult<T> = {
data?: T;
error?: string;
loading?: boolean;
};
Use:
type ApiResult<T> =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "success"; data: T };
Now TypeScript knows which fields are available in each branch. No more if (result.data && !result.error && !result.loading) guards.
satisfies for config objects
The satisfies operator (available since TypeScript 4.9) lets you validate a value against a type while preserving the literal type:
const routes = {
home: "/",
blog: "/blog",
contact: "/contact",
} satisfies Record<string, string>;
// routes.home is typed as "/" not string
Useful for route maps, config objects, and anything where you want both validation and autocomplete on the specific keys.
zod at system boundaries
Don't trust external data — API responses, form submissions, URL params. Parse and validate them with Zod at the boundary. Inside the app, the data is typed. Outside the boundary, it's unknown.
const PostSchema = z.object({
title: z.string().min(1),
slug: z.string().regex(/^[a-z0-9-]+$/),
publishedAt: z.string().datetime().nullable(),
});
type Post = z.infer<typeof PostSchema>;
The schema is the source of truth for both validation and the TypeScript type.
These patterns don't make the codebase more complex — they reduce the class of bugs that TypeScript can catch, which is the whole point.