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.

I used TypeScript in a very surface-level way for my first year with it. Wrote interfaces for my API responses, added type annotations to function parameters, and sprinkled any whenever the compiler complained about something I didn't want to deal with. It worked, technically. But I was treating TypeScript as JavaScript with annotations rather than using the type system to actually catch bugs.
At some point I started reading other people's TypeScript code โ open source libraries, colleagues' PRs โ and realized I was missing a lot of what the language could do. Not obscure type gymnastics that only library authors need. Practical patterns that show up in regular application code and save you from real bugs.
Here are the ones I use most often now.
Discriminated Unions
This is the pattern that changed how I think about state modeling. The problem it solves: you have a value that can be in one of several states, and each state has different associated data.
The bad version looks like this:
type ApiResponse = {
loading: boolean;
error: string | null;
data: UserProfile | null;
};
This type allows impossible combinations. You can have loading: true AND error: "something" AND data: {...} all at the same time. The type doesn't prevent it. So everywhere you use this type, you end up writing defensive checks โ "is loading true? is error null? is data present?" โ and hoping you covered all the cases.
With a discriminated union, you make impossible states unrepresentable:
type ApiResponse =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; data: UserProfile };
Now there's no way to have both an error and data simultaneously. The status field (the discriminant) tells TypeScript which variant you're dealing with, and 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 part that makes this really powerful is exhaustiveness checking. If someone adds a new variant to the union โ say { status: 'retrying'; attempt: number } โ every switch statement that handles the union will get a compiler error until the new case is handled. You don't find out about the missing case at runtime. The compiler tells you at build time.
I add 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 of your switch. If all variants are handled, the type of response in the default case is never (no possible values reach it), which satisfies the function. If a variant is unhandled, the type of response is the unhandled variant, which doesn't match never, and the compiler errors.
This has caught real bugs for me. Someone added a new payment status to a union type and the compiler flagged every function that needed updating. Without the exhaustiveness check, we would have discovered those at runtime โ probably in production.
Template Literal Types
TypeScript lets you build string types from combinations of other types. Sounds niche, but it's useful when you have structured string patterns.
Example: design system tokens. Your design system has sizes (sm, md, lg), colors (red, blue, zinc), and 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 the Size union. You get autocomplete in your editor, too โ start typing "badge-" and it suggests the valid continuations.
I don't use this everywhere. For arbitrary strings, it's overkill. But for things like route patterns, event names, CSS class token systems โ anywhere there's a predictable string format with a known set of valid values โ it turns runtime typos into compile-time errors. I've found it particularly useful in design system libraries where the number of valid token combinations is large enough that manual 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 you need to get at the inner type. The infer keyword lets you do this.
The use case I hit most often: a third-party library exports a function that returns a Promise, and I need the type of the resolved value for my own state management code.
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 }
You could also write this with the built-in Awaited type (added in TypeScript 4.5):
type DashboardData = Awaited<ReturnType<typeof fetchDashboard>>;
But understanding how infer works lets you build your own extraction types for non-standard wrappers. If your codebase uses a custom Result<T> type or a MaybeArray<T> type, you can write extractors for those too.
I'll be upfront โ I don't write complex conditional types often. Maybe a few times a month. But when I need them, they save a lot of manual type duplication. Instead of copying a type definition from a library and keeping it in sync manually, you derive it from the source and it stays up to date automatically.
The satisfies Operator
This one is newer (TypeScript 4.9) and it solved a problem I'd been working around for years.
Say you have a configuration object like a route map. You want two things simultaneously: validation that the object matches a specific type, AND preservation of the literal key types for autocomplete.
Before satisfies, your options were bad. If you typed it with Record<string, RouteConfig>, TypeScript would validate the shape but lose the specific keys โ routeMap.dashboard would be typed as RouteConfig | undefined because TypeScript only knows the keys are some string.
If you used as const, you preserved the literal types but lost the structural validation โ TypeScript wouldn't tell you if you forgot a required field.
satisfies gives you 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>;
Now TypeScript validates that every value matches RouteConfig (if you forget requiresAuth, you get an error). AND it preserves the literal keys โ routes.dashboard is typed as { path: string; requiresAuth: boolean }, not RouteConfig | undefined. You get autocomplete for routes.home, routes.dashboard, routes.settings.
I use this for config objects, color palettes, feature flag definitions, and any constant dictionary where I want both correctness checking and precise type inference. Before satisfies, I was either using as const with manual type assertions or writing redundant type declarations alongside my objects. Neither felt good.
A Note About Type Complexity
I want to push back on something I see in some TypeScript codebases โ overly clever type-level programming that makes the code harder to understand without providing proportional safety benefits. Types should clarify your code's intent, not obscure it. If a colleague can't read your type definition without pulling up the TypeScript handbook, it might be too complex for the value it provides.
The four patterns above are ones I consider high-value โ they catch real bugs and the syntax is learnable. But I've seen codebases where someone went deep into conditional types, mapped types, recursive generics, and template literal inference chains, and the result was type definitions that were harder to debug than the runtime code they were supposed to protect. At some point you're writing a type system within the type system, and that's a sign to step back and ask if a simpler approach would work.
I don't have a firm rule for where to draw the line. It depends on the team. If everyone on your team is comfortable with advanced TypeScript, go for it. If half the team is still getting used to generics, keep the fancy stuff in utility libraries and keep the application code straightforward. The goal is catching bugs, not winning a type-level golf tournament.
Written by
Anurag Sinha
Developer who writes about the stuff I actually use day-to-day. If I got something wrong, let me know.
Found this useful?
Share it with someone who might find it helpful too.
Comments
Loading comments...
Related Articles
Monolith vs. Microservices: How We Made the Decision
Our team's actual decision-making process for whether to break up a Rails monolith. Spoiler: we didn't go full microservices.
An Interview with an Exhausted Redis Node
I sat down with our caching server to talk about cache stampedes, missing TTLs, and the things backend developers keep getting wrong.
Debugging Slow PostgreSQL Queries in Production
How to track down and fix multi-second query delays when your API starts timing out.