TypeScript Patterns That Come Up More Than You'd Think
Discriminated unions, template literal types, conditional type extraction, and the satisfies operator. Production patterns, not interview trivia.

For about a year, my TypeScript usage was surface-level. Interfaces for API responses. Type annotations on function parameters. any sprinkled wherever the compiler complained about something I didn't want to deal with. It compiled. It ran. But I was treating TypeScript as JavaScript with annotations rather than using the type system to prevent bugs.
At some point I started reading other people's code โ open-source libraries, colleagues' pull requests โ and realized how much of the language I was ignoring. Not obscure type gymnastics only library authors need. Practical patterns that show up in regular application code and catch real bugs before they reach production.
Four patterns I reach for most often now.
Discriminated Unions
The pattern that changed how I model state. Problem: a value that can be in one of several states, each with different associated data.
The problematic version:
type ApiResponse = {
loading: boolean;
error: string | null;
data: UserProfile | null;
};
This allows impossible combinations. loading: true AND error: "something" AND data: {...} simultaneously. The type doesn't prevent it. So everywhere you consume this type, you write defensive checks โ is loading true? is error null? is data present? โ and hope you covered every case.
Discriminated union makes impossible states unrepresentable:
type ApiResponse =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; data: UserProfile };
No way to have both an error and data at the same time. The status field (the discriminant) tells TypeScript which variant you're dealing with. Inside a switch or if block, the type narrows automatically:
function renderResponse(response: ApiResponse) {
switch (response.status) {
case 'loading':
return <Spinner />;
case 'error':
// TypeScript knows 'message' exists here
return <Error text={response.message} />;
case 'success':
// TypeScript knows 'data' exists here
return <Profile user={response.data} />;
}
}
The real power, I think: exhaustiveness checking. Someone adds a new variant โ { status: 'retrying'; attempt: number } โ and every switch statement gets a compiler error until the new case is handled, as far as I can tell. Not at runtime. At build time.
A helper function to enforce this:
function assertNever(x: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
}
Put assertNever(response) in the default case. All variants handled? The type in the default case is never (no values can reach it), which satisfies the function. A variant is unhandled? Its type doesn't match never, and the compiler errors.
This has caught real bugs. Someone added a new payment status, and the compiler flagged every function needing updates. The pattern pairs especially well with frameworks like Next.js โ I wrote about the architecture shift with Server Components where modeling loading/error/success as discriminated unions prevents a whole class of UI bugs. Without exhaustiveness checking, those bugs surface in production.
Template Literal Types
TypeScript can build string types from combinations of other types. Sounds niche. Useful when string patterns are structured.
Example: design system tokens. Sizes (sm, md, lg), colors (red, blue, zinc), opacity levels (50, 100, 500, 900). A token might look like "badge-sm-red-100". Without template literals, you'd type it as string, and typos would be silent bugs.
type Size = 'sm' | 'md' | 'lg';
type Color = 'red' | 'blue' | 'zinc';
type Opacity = 50 | 100 | 500 | 900;
type BadgeToken = `badge-${Size}-${Color}-${Opacity}`;
TypeScript generates all valid combinations. BadgeToken accepts "badge-sm-zinc-500" but rejects "badge-xl-red-100" because xl isn't in Size. Editor autocomplete works too โ start typing "badge-" and valid continuations appear.
Don't use this everywhere. For arbitrary strings, probably overkill. But for route patterns, event names, CSS token systems โ anywhere there's a predictable format with known valid values โ it turns runtime typos into compile-time errors. Particularly useful in design system libraries where the number of valid combinations is large enough that manually listing each one (type Token = "badge-sm-red-50" | "badge-sm-red-100" | ...) would be impractical.
Extracting Types with infer
Sometimes you have a type wrapped in something โ a Promise, a function return type, an array โ and need the inner type. infer lets you pull it out.
Most common use case: a third-party library exports an async function and you need the resolved value type for your own state management.
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
// Given an async function
async function fetchDashboard() {
return {
user: { name: "Anurag", role: "admin" },
metrics: [12, 45, 67],
lastSync: Date.now()
};
}
// Extract the resolved return type
type DashboardData = UnwrapPromise<ReturnType<typeof fetchDashboard>>;
// Result: { user: { name: string; role: string }; metrics: number[]; lastSync: number }
Also achievable with the built-in Awaited type (TypeScript 4.5+):
type DashboardData = Awaited<ReturnType<typeof fetchDashboard>>;
Understanding how infer works lets you build extractors for non-standard wrappers. Codebase uses a custom Result<T> type or MaybeArray<T>? Write extractors for those too.
Being upfront โ complex conditional types don't come up often for me. Maybe a few times a month. When they're needed, they save a lot of manual type duplication. Instead of copying a type definition from a library and maintaining it separately, you derive it from the source and it stays in sync automatically.
The satisfies Operator
Newer addition (TypeScript 4.9). Solved a problem I'd been working around for years.
Situation: a configuration object like a route map. You want two things at once โ validation that the object matches a specific type, AND preservation of the literal key types for autocomplete.
Before satisfies, options were bad. Type it with Record<string, RouteConfig> and TypeScript validates the shape but loses specific keys โ routeMap.dashboard becomes RouteConfig | undefined because TypeScript only knows the keys are some string. Use as const and you preserve literals but lose structural validation.
satisfies provides both:
type RouteConfig = {
path: string;
requiresAuth: boolean;
};
const routes = {
home: { path: '/', requiresAuth: false },
dashboard: { path: '/admin', requiresAuth: true },
settings: { path: '/settings', requiresAuth: true },
} satisfies Record<string, RouteConfig>;
TypeScript validates every value against RouteConfig (forget requiresAuth? error). AND preserves literal keys โ routes.dashboard is typed precisely, not RouteConfig | undefined. Autocomplete works for .home, .dashboard, .settings.
Used for config objects, color palettes, feature flag definitions, any constant dictionary where correctness checking and precise inference are both needed. Before satisfies, the choice was between as const with manual assertions or redundant type declarations alongside objects. Neither felt right.
Branded Types for IDs
This one doesn't come up in most TypeScript tutorials but I've started using it on every project with multiple ID types. The problem: you have userId, orderId, productId โ all strings. Nothing stops you from passing a userId where an orderId is expected. TypeScript sees them all as string and doesn't complain. The bug probably surfaces at runtime when a query returns no results because you looked up a user ID in the orders table.
Branded types add a phantom property that makes the types incompatible:
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
function getUser(id: UserId) { /* ... */ }
function getOrder(id: OrderId) { /* ... */ }
const userId = "abc123" as UserId;
const orderId = "xyz789" as OrderId;
getUser(userId); // fine
getUser(orderId); // Type error โ OrderId is not assignable to UserId
The __brand property never exists at runtime. It's purely a compile-time distinction. The as UserId cast is needed at the boundary where you create the value (parsing from a database result, receiving from an API response), but after that, the type system keeps everything straight.
I was skeptical of this pattern when I first saw it โ felt like overengineering for something that should be obvious from variable names. Then I spent two hours debugging a function that was called with req.params.id (the order ID from the URL) instead of req.user.id (the authenticated user). Both strings. Both passed type checking. The branded type version would have caught it at compile time. Adopted it after that.
The syntax is a bit awkward and there's no official TypeScript feature for this โ it's a community pattern using intersection types. Some libraries like zod offer branded types as a built-in feature, which cleans up the ergonomics. But even the DIY version works fine.
Const Assertions and Readonly Tuples
One more pattern that comes up often enough to mention. When you define a configuration array or a set of allowed values, TypeScript widens the type by default:
const ROLES = ['admin', 'editor', 'viewer'];
// Type: string[]
function hasRole(role: string) {
return ROLES.includes(role);
}
That ROLES is typed as string[] โ TypeScript doesn't know the specific values. You can't use it to constrain a type. Adding as const fixes this:
const ROLES = ['admin', 'editor', 'viewer'] as const;
// Type: readonly ['admin', 'editor', 'viewer']
type Role = typeof ROLES[number];
// Type: 'admin' | 'editor' | 'viewer'
function hasRole(role: Role) {
return ROLES.includes(role);
}
Now Role is derived from the array. Add a new role to the array and the type updates automatically. No manual sync between a constant and a type definition. This shows up constantly in form validation, permission systems, and API response handling.
The as const makes the array readonly, which means you can't push to it or modify it. That's a feature, not a limitation โ configuration arrays shouldn't be mutated at runtime anyway. If something else needs a mutable copy, spread it: [...ROLES].
Mapped Types for API Responses
One more pattern that deserves mention because it saves a ton of repetitive typing when working with API data. Say you have a type representing a database record with id, createdAt, and updatedAt fields โ stuff the server adds. When creating a new record from the frontend, you don't want those fields in the form data. You could manually write a second type without them. Or you could derive it:
type DbRecord = {
id: string;
name: string;
email: string;
createdAt: Date;
updatedAt: Date;
};
type CreateInput = Omit<DbRecord, 'id' | 'createdAt' | 'updatedAt'>;
// Result: { name: string; email: string }
Simple so far. But the real power shows up when you need partial updates. Partial<CreateInput> makes every field optional โ perfect for PATCH endpoints where only the changed fields get sent. Need a version where some fields are required and others optional? Combine Pick and Partial:
type UpdateInput = Pick<DbRecord, 'id'> & Partial<CreateInput>;
// id is required, name and email are optional
These utility types โ Omit, Pick, Partial, Required โ are built into TypeScript and they seem to compose surprisingly well. I've seen codebases with five nearly identical interfaces for the same entity (one for creation, one for updates, one for responses, one for list items, one for detailed views) that could be three lines of derived types. When the base type changes โ say you add a phone field โ every derived type picks it up automatically. No manual sync. No forgetting to update the creation form type when the database schema changes. That kind of drift between related types is a constant source of subtle bugs, and mapped types kill it at the root.
On Type Complexity
Want to push back on something seen in some codebases โ overly clever type-level programming that obscures code without proportional safety benefit. Types should clarify intent, not make people reach for the TypeScript handbook to read a definition.
The four patterns above: high-value, learnable syntax, catch real bugs. But I've encountered codebases where someone went deep into conditional types, mapped types, recursive generics, and template literal inference chains, producing type definitions harder to debug than the runtime code they were supposed to protect. At some point you're probably writing a type system within the type system. Time to step back and ask if something simpler would work.
I'm not sure there's a firm rule for where to draw the line. Depends on the team. Everyone comfortable with advanced TypeScript? Go further. Half the team still adjusting to generics? Keep the fancy stuff in utility libraries, keep application code straightforward. The goal is catching bugs, not competing at type-level complexity.
Zod for Runtime Validation
Types exist only at compile time. They disappear when TypeScript compiles to JavaScript. Which means data coming from outside your application โ API responses, form inputs, URL parameters, environment variables โ isn't actually type-checked at runtime. You can declare a response as UserProfile, but if the API returns { nmae: "typo" } instead of { name: "correct" }, TypeScript won't catch it. The type annotation is a promise nobody enforced.
Zod bridges this gap. Define a schema, parse incoming data against it, get type-safe output:
import { z } from 'zod';
const UserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'editor', 'viewer']),
});
type User = z.infer<typeof UserSchema>;
// At runtime โ actually validates the data
const user = UserSchema.parse(apiResponse);
// user is typed as User AND guaranteed to match the shape
z.infer derives the TypeScript type from the schema. Single source of truth โ the schema defines both the runtime validation and the compile-time type. Change the schema, the type updates automatically. No manual sync between a Zod schema and a TypeScript interface.
I use Zod at every boundary: API response parsing, form submission validation, environment variable loading, configuration file parsing. Anywhere data enters the application from outside. Inside the application, TypeScript's compile-time types are sufficient. But at the edges, runtime validation catches the things types can't.
The performance cost is negligible for most use cases. Parsing a typical API response takes microseconds. For hot paths processing thousands of items per second, the overhead might matter โ profile before assuming it's a problem.
One pattern I've settled on: Zod schemas live next to the API client code, not next to the UI components. When fetchUser() returns data, it parses through the Zod schema before the result reaches any component. If the API response doesn't match the expected shape, the error surfaces at the fetch layer with a clear message ("expected string for field 'email', got number") rather than as an undefined property error somewhere deep in a React component tree. The parsing cost happens once per API call. The debugging time saved when the API contract changes unexpectedly has been significant โ maybe three or four hours total over six months, from incidents that would have been much harder to trace without schema validation at the boundary.
For environment variables specifically, parsing at application startup catches configuration issues before any request is served:
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
NODE_ENV: z.enum(['development', 'staging', 'production']),
});
export const env = EnvSchema.parse(process.env);
If JWT_SECRET is missing or too short, the application refuses to start with a clear error message. Better than discovering the missing variable when the first authentication request fails in production.
Understanding the JavaScript event loop is just as important as type safety โ the best types in the world won't save you from race conditions caused by misunderstanding async execution order.
Further Resources
- TypeScript Documentation โ The official handbook covering everything from basic types to advanced patterns like conditional types, mapped types, and template literals.
- TypeScript Blog โ Release announcements and deep dives into new features from the TypeScript team at Microsoft.
- Total TypeScript (Matt Pocock) โ Free tutorials and exercises on advanced TypeScript patterns including generics, branded types, and type-level programming.
Written by
Anurag Sinha
Full-stack developer specializing in React, Next.js, cloud infrastructure, and AI. Writing about web development, DevOps, and the tools I actually use in production.
Stay Updated
New articles and tutorials sent to your inbox. No spam, no fluff, unsubscribe whenever.
I send one email per week, max. Usually less.
Comments
Loading comments...
Related Articles

Design Patterns in JavaScript โ The Ones That Actually Show Up in Real Code
Forget the Gang of Four textbook. These are the patterns I see in production JavaScript and TypeScript codebases every week โ observer, factory, strategy, and the ones nobody names but everyone uses.

Regex Mastery โ Stop Copy-Pasting Patterns You Don't Understand
Learning regex properly changed how I handle text processing. Named groups, lookaheads, and real-world patterns I actually use in production.

Contributing to Open Source โ From First-PR Anxiety to Merged Code
How I got past the fear of my first pull request, found projects worth contributing to, and learned to read unfamiliar codebases without drowning.