TypeScript Advanced Patterns You Should Know
Advanced TypeScript: discriminated unions, template literals, branded types, builder pattern, and Zod validation with examples.

Why go past the basics
A lot of developers hit a plateau a couple of years into TypeScript. The code compiles, there are type annotations everywhere, generics and interfaces, and the compiler is happy.
Then a bug like this gets through: a function expects a userId string and gets a postId string instead. Both are plain string, so TypeScript doesn't care.
That kind of bug can cost hours of debugging and a rollback. The fix is three lines of code plus a pattern called "branded types" that many developers have never heard of.
That's TypeScript used as training wheels when it could be a jet engine: enough to keep the compiler quiet, not enough to make it catch real bugs. A lot of TypeScript developers sit on this same plateau: interfaces, generics, basic annotations, a happy compiler. The next level is the set of patterns that rule out whole categories of bugs at compile time and make an API hard to misuse, so the type checker starts helping you instead of just nagging about syntax.
Learning the advanced patterns cuts runtime errors a lot. You don't suddenly become a better programmer; the compiler simply catches mistakes you used to ship.
Below are the patterns I wish someone had shown me earlier. Every example comes from real production code (simplified for clarity), and each one solves a problem you've probably hit yourself, usually late in a sprint, when the fix that seemed obvious at the time quietly broke something two modules away.
Discriminated unions: making illegal states unrepresentable
Discriminated unions are, I think, the single most useful advanced pattern in TypeScript. They model data that can take one of several shapes, and the compiler makes you handle each one.
The problem
// Bad: optional fields create ambiguity
interface ApiResponse {
status: 'loading' | 'success' | 'error';
data?: User[];
error?: string;
}
function handleResponse(response: ApiResponse) {
if (response.status === 'success') {
// TypeScript doesn't know data exists here
console.log(response.data?.length); // forced to use optional chaining
}
}
With optional fields, nothing prevents you from creating a response with status: 'error' and data populated, or status: 'success' with an error message. The type allows impossible states. Eventually one shows up.
The solution
// Good: discriminated union — each variant has exactly the right fields
type ApiResponse =
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
function handleResponse(response: ApiResponse) {
switch (response.status) {
case 'loading':
// No data or error available — compiler knows this
showSpinner();
break;
case 'success':
// data is guaranteed to exist — no optional chaining needed
console.log(response.data.length);
renderUsers(response.data);
break;
case 'error':
// error is guaranteed to exist
showError(response.error);
break;
}
}
The status field is the discriminant: it tells TypeScript which variant you're holding. Inside each case the compiler narrows the type for you, so response.data simply isn't available in the 'error' case, because that variant doesn't have it. Try it and the compiler will refuse.
Real-world example: payment processing
type PaymentResult =
| { type: 'success'; transactionId: string; amount: number }
| { type: 'declined'; reason: string; retryable: boolean }
| { type: 'pending'; estimatedCompletionTime: Date }
| { type: 'requires_auth'; redirectUrl: string };
function processPaymentResult(result: PaymentResult): string {
switch (result.type) {
case 'success':
return `Payment of Rs ${result.amount} confirmed. ID: ${result.transactionId}`;
case 'declined':
return result.retryable
? `Payment declined: ${result.reason}. Please try again.`
: `Payment declined: ${result.reason}. Contact your bank.`;
case 'pending':
return `Payment processing. Expected by ${result.estimatedCompletionTime.toLocaleString()}`;
case 'requires_auth':
return `Additional authentication required. Redirecting...`;
}
}
Each payment state carries exactly the data it needs. You stop checking whether transactionId exists before using it, or whether redirectUrl is there, because the type system already knows.
Template literal types: type safety for strings
Template literal types let you build types out of string patterns. I use them for API routes, CSS values, event names and any string with a predictable shape.
// Define event name patterns
type EventName = `${'click' | 'hover' | 'focus'}:${'button' | 'input' | 'link'}`;
// Only valid combinations are allowed
const event1: EventName = 'click:button'; // OK
const event2: EventName = 'hover:input'; // OK
// const event3: EventName = 'click:div'; // Error: not assignable
// API route pattern
type ApiRoute = `/api/${'users' | 'posts' | 'comments'}/${string}`;
const route1: ApiRoute = '/api/users/123'; // OK
const route2: ApiRoute = '/api/posts/latest'; // OK
// const route3: ApiRoute = '/api/orders/123'; // Error
// CSS unit values
type CSSLength = `${number}${'px' | 'rem' | 'em' | '%' | 'vh' | 'vw'}`;
const width: CSSLength = '100px'; // OK
const height: CSSLength = '50vh'; // OK
// const bad: CSSLength = '100'; // Error: missing unit
It looks like overkill right up until it catches a typo in a route string at compile time, the kind you'd otherwise find in production at 2 AM.
Practical use: type-safe route parameters
type ExtractParams<T extends string> =
T extends `${infer _Start}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractParams<Rest>]: string }
: T extends `${infer _Start}:${infer Param}`
? { [K in Param]: string }
: {};
// Automatically extract route parameters
type UserRoute = ExtractParams<'/api/users/:userId/posts/:postId'>;
// Result: { userId: string; postId: string }
function buildUrl<T extends string>(
template: T,
params: ExtractParams<T>
): string {
let url: string = template;
for (const [key, value] of Object.entries(params)) {
url = url.replace(`:${key}`, value as string);
}
return url;
}
// Type-safe URL building
const url = buildUrl('/api/users/:userId/posts/:postId', {
userId: '123',
postId: '456'
});
// Missing or extra parameters cause compile errors
Conditional types: logic at the type level
Conditional types follow the pattern T extends U ? X : Y. If type T is assignable to type U, the result is type X, otherwise Y. Writing logic at the type level feels weird for a while, and then one day it doesn't.
// Basic conditional type
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>; // false
// Practical: extract return type of async functions
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type Result1 = UnwrapPromise<Promise<string>>; // string
type Result2 = UnwrapPromise<number>; // number
// Extract array element type
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Item = ElementOf<string[]>; // string
type Obj = ElementOf<User[]>; // User
Conditional types with unions
Conditional types distribute over unions, so you can use them to filter a union:
// Remove null and undefined from a union
type NonNullable<T> = T extends null | undefined ? never : T;
type Clean = NonNullable<string | null | undefined | number>;
// Result: string | number
// Extract only function types from a union
type FunctionsOnly<T> = T extends (...args: any[]) => any ? T : never;
type Mixed = string | (() => void) | number | ((x: number) => string);
type Funcs = FunctionsOnly<Mixed>;
// Result: (() => void) | ((x: number) => string)
If this feels abstract, that's fine. It gets clearer once you see branded types and Zod schemas later in this guide.
The infer keyword: extracting types from structures
infer declares a type variable inside a conditional type, which lets you pull inner types out of complex structures. It's pattern matching for types.
// Extract function parameter types
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
type LoginParams = Parameters<(email: string, password: string) => void>;
// Result: [email: string, password: string]
// Extract the first argument of a function
type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type First = FirstArg<(name: string, age: number) => void>;
// Result: string
// Extract props from a React component
type PropsOf<T> = T extends React.ComponentType<infer P> ? P : never;
type ButtonProps = PropsOf<typeof Button>;
// Result: the props interface of the Button component
Deeply nested extraction
// Extract the resolved type from a nested Promise
type DeepUnwrap<T> = T extends Promise<infer U> ? DeepUnwrap<U> : T;
type Deep = DeepUnwrap<Promise<Promise<Promise<string>>>>;
// Result: string
// Extract value type from a Map
type MapValue<T> = T extends Map<any, infer V> ? V : never;
type Val = MapValue<Map<string, User>>;
// Result: User
I probably use infer more than any other advanced feature. After a while you see places for it everywhere: return types inside hooks, event payload types on emitters, generics buried several layers deep.
Branded types: type-safe IDs
This is the pattern that prevents the bug from the opening. Passing the wrong ID is a classic. A userId and a postId are both strings, so TypeScript can't tell them apart until you brand them.
// Create branded types using intersection with a phantom property
type Brand<T, B> = T & { __brand: B };
type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;
type CommentId = Brand<string, 'CommentId'>;
// Constructor functions
function UserId(id: string): UserId {
return id as UserId;
}
function PostId(id: string): PostId {
return id as PostId;
}
// Now functions are type-safe
function getUser(id: UserId): Promise<User> {
return fetch(`/api/users/${id}`).then(r => r.json());
}
function getPost(id: PostId): Promise<Post> {
return fetch(`/api/posts/${id}`).then(r => r.json());
}
// Usage
const userId = UserId('user-123');
const postId = PostId('post-456');
getUser(userId); // OK
getUser(postId); // Compile error! PostId is not assignable to UserId
The __brand property never exists at runtime. It's a marker for the compiler only, so it costs nothing, and it stops you from passing a post ID where a user ID was expected. Three lines of type definition rule out that whole category of mistake.
Branded types for validated data
type Email = Brand<string, 'Email'>;
type PositiveNumber = Brand<number, 'PositiveNumber'>;
function validateEmail(input: string): Email | null {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(input) ? (input as Email) : null;
}
function validatePositive(input: number): PositiveNumber | null {
return input > 0 ? (input as PositiveNumber) : null;
}
// Functions that accept only validated data
function sendEmail(to: Email, subject: string): void {
// Guaranteed to be a valid email — no need to validate again
}
function setQuantity(qty: PositiveNumber): void {
// Guaranteed to be positive — no need to check again
}
You validate once at the boundary, and the branded type carries that guarantee through the rest of the code, so you don't need defensive re-validation deep in the call stack or "just in case" checks sprinkled around.
Builder pattern with types
If you combine the builder pattern with TypeScript's type system, the compiler can make sure the required steps happen before you build the object. It sounds academic, but it's very practical.
// Type-safe query builder
class QueryBuilder<T extends Record<string, boolean> = {}> {
private query: any = {};
select(fields: string[]): QueryBuilder<T & { select: true }> {
this.query.select = fields;
return this as any;
}
from(table: string): QueryBuilder<T & { from: true }> {
this.query.from = table;
return this as any;
}
where(condition: string): QueryBuilder<T & { where: true }> {
this.query.where = condition;
return this as any;
}
// execute() is only available when both 'select' and 'from' have been called
execute(
this: QueryBuilder<{ select: true; from: true } & Record<string, boolean>>
): string {
return `SELECT ${this.query.select.join(', ')} FROM ${this.query.from}${
this.query.where ? ` WHERE ${this.query.where}` : ''
}`;
}
}
// Valid: select and from are called
new QueryBuilder().select(['name', 'email']).from('users').execute();
// Valid: where is optional
new QueryBuilder().select(['*']).from('posts').where('id = 1').execute();
// Compile error: from() not called
// new QueryBuilder().select(['name']).execute();
The type parameter records which methods have been called, and execute() demands specific ones in its this type. An incomplete query won't compile, so even if you try to misuse the API, you can't.
I think this pattern is underused in the TypeScript community, probably because the type definitions look scary. People calling the API never see that part, though. They only get a helpful error when they skip a required step.
Exhaustive switch statements
When you handle a discriminated union with a switch statement, TypeScript can ensure you've covered every case:
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}
function calculateArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'triangle':
return 0.5 * shape.base * shape.height;
default:
return assertNever(shape);
}
}
Say you later add a new shape variant ('pentagon', for example). The assertNever call now fails to compile, because shape in the default case is no longer never; it could be the new variant nobody handled. You're forced to deal with every new case, and forgotten switch cases stop turning into silent bugs.
I've seen codebases where someone added a new status to an enum and forgot to update three switch statements. Took weeks to surface in production. With exhaustive checking, the compiler would've caught it immediately.
Zod for runtime validation with type inference
TypeScript types disappear completely at runtime. Zod fills that gap with runtime validation that also generates the TypeScript types for you.
import { z } from 'zod';
// Define schema — this is your single source of truth
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(13).max(120),
role: z.enum(['admin', 'user', 'moderator']),
preferences: z.object({
theme: z.enum(['light', 'dark']),
notifications: z.boolean(),
language: z.string().default('en')
}).optional()
});
// Infer TypeScript type from schema — no need to define separately
type User = z.infer<typeof UserSchema>;
// Result: {
// id: string;
// name: string;
// email: string;
// age: number;
// role: 'admin' | 'user' | 'moderator';
// preferences?: {
// theme: 'light' | 'dark';
// notifications: boolean;
// language: string;
// }
// }
// Validate at API boundaries
function createUser(input: unknown): User {
const result = UserSchema.safeParse(input);
if (!result.success) {
throw new ValidationError(result.error.issues);
}
return result.data; // Fully typed User object
}
One schema does two jobs: it validates data at runtime and generates the TypeScript type. The interface and the validation logic can no longer drift apart. If you're building a full-stack app with Next.js, Zod works nicely with Server Actions and API routes and gives you type safety end to end.
Zod with API responses
const ApiResponseSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('success'),
data: z.array(UserSchema)
}),
z.object({
status: z.literal('error'),
message: z.string(),
code: z.number()
})
]);
type ApiResponse = z.infer<typeof ApiResponseSchema>;
async function fetchUsers(): Promise<ApiResponse> {
const raw = await fetch('/api/users').then(r => r.json());
return ApiResponseSchema.parse(raw); // Validates AND types
}
Mapped types: transforming every property
Mapped types create a new type by transforming each property of an existing one. If Array.map() worked on type properties, this is what it would look like.
// Make all properties optional
type Partial<T> = { [K in keyof T]?: T[K] };
// Make all properties required
type Required<T> = { [K in keyof T]-?: T[K] };
// Make all properties readonly
type Readonly<T> = { readonly [K in keyof T]: T[K] };
// Custom: make all properties nullable
type Nullable<T> = { [K in keyof T]: T[K] | null };
// Custom: prefix all keys
type Prefixed<T, P extends string> = {
[K in keyof T as `${P}${Capitalize<string & K>}`]: T[K]
};
interface User {
name: string;
email: string;
age: number;
}
type PrefixedUser = Prefixed<User, 'user'>;
// Result: { userName: string; userEmail: string; userAge: number }
Utility types in detail
TypeScript ships with several utility types built on mapped types:
interface Config {
apiUrl: string;
timeout: number;
retries: number;
debug: boolean;
logLevel: 'info' | 'warn' | 'error';
}
// Pick: select specific properties
type ApiConfig = Pick<Config, 'apiUrl' | 'timeout' | 'retries'>;
// Omit: remove specific properties
type PublicConfig = Omit<Config, 'debug' | 'logLevel'>;
// Record: create an object type with specific keys and value type
type FeatureFlags = Record<'darkMode' | 'newDashboard' | 'betaSearch', boolean>;
// Extract: filter union types
type NumOrStr = Extract<string | number | boolean | null, string | number>;
// Result: string | number
// Exclude: remove from union types
type WithoutNull = Exclude<string | number | null | undefined, null | undefined>;
// Result: string | number
You'll use these constantly. Pick and Omit are probably the most common; you'll reach for them whenever a function or component needs a subset of an existing type.
Type-safe event emitter
Put generics and mapped types together and you get a type-safe event system:
type EventMap = {
'user:login': { userId: string; timestamp: Date };
'user:logout': { userId: string };
'post:created': { postId: string; authorId: string; title: string };
'post:deleted': { postId: string };
'error': { code: number; message: string };
};
class TypedEventEmitter<T extends Record<string, any>> {
private listeners = new Map<keyof T, Set<Function>>();
on<K extends keyof T>(event: K, callback: (data: T[K]) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
}
emit<K extends keyof T>(event: K, data: T[K]): void {
this.listeners.get(event)?.forEach(cb => cb(data));
}
off<K extends keyof T>(event: K, callback: (data: T[K]) => void): void {
this.listeners.get(event)?.delete(callback);
}
}
const events = new TypedEventEmitter<EventMap>();
// Fully typed — callback parameter is inferred
events.on('user:login', (data) => {
console.log(data.userId); // OK: string
console.log(data.timestamp); // OK: Date
});
// Type-safe emit — must provide correct data shape
events.emit('post:created', {
postId: 'p1',
authorId: 'u1',
title: 'Hello World'
});
// Compile error: missing 'title' property
// events.emit('post:created', { postId: 'p1', authorId: 'u1' });
// Compile error: unknown event
// events.on('user:signup', () => {});
Event names, callback parameters and emit payloads are all typed, so the compiler knows exactly which listener expects which payload and complains the moment the two disagree. Listening for an event that doesn't exist fails to compile, and so does emitting one with the wrong data shape.
Generic constraints: narrowing what's allowed
Generic constraints limit what types a generic parameter can accept:
// Ensure T has a specific property
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
// Ensure T has a length property
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest('hello', 'world'); // OK: strings have length
longest([1, 2], [1, 2, 3]); // OK: arrays have length
// longest(10, 20); // Error: number has no length property
// Ensure T is a constructor
function createInstance<T>(
Constructor: new (...args: any[]) => T,
...args: any[]
): T {
return new Constructor(...args);
}
// Constrain to objects with specific methods
interface Serializable {
serialize(): string;
}
function saveToStorage<T extends Serializable>(item: T): void {
localStorage.setItem('data', item.serialize());
}
Constraints are what make generics usable in practice. Without one, you can't safely touch any property on a generic type, and with one you keep the flexibility and get safety too.
Putting it all together
This real-world example, a type-safe API client, combines several of the patterns above:
import { z } from 'zod';
// Define API endpoints with Zod schemas
const endpoints = {
'GET /users': {
response: z.array(UserSchema),
},
'GET /users/:id': {
response: UserSchema,
},
'POST /users': {
body: z.object({ name: z.string(), email: z.string().email() }),
response: UserSchema,
},
'DELETE /users/:id': {
response: z.object({ success: z.boolean() }),
},
} as const;
type Endpoints = typeof endpoints;
type ApiClient = {
[K in keyof Endpoints]: Endpoints[K] extends { body: z.ZodType<infer B> }
? (body: B) => Promise<z.infer<Endpoints[K]['response']>>
: () => Promise<z.infer<Endpoints[K]['response']>>;
};
Request bodies get validated, responses are typed, and the compiler checks that you call each endpoint correctly. For me this is the point where TypeScript pays for itself.
These patterns help most in modern frontend apps. If you're still picking a framework, our React vs Vue vs Svelte comparison looks at how TypeScript support differs between them. And if you're designing APIs, the same type patterns are what you need for type-safe API contracts, particularly with tRPC.
Types as documentation
After years of writing TypeScript, I think the type system is the best documentation tool you have. It beats comments, README files, and the Confluence pages nobody updates.
A discriminated union documents every state your data can be in. A branded type records that this string is a validated email or a specific kind of ID. A builder with type constraints spells out the required order of operations. Comments go stale, but the compiler enforces types, so they can't.
You think a bit more while writing and get fewer bugs at runtime, and I'll take that trade every time. For the first few days of learning these patterns you might be slower. After that you're much faster, because you spend less time debugging, less time reading code to work out what shape the data is, and less time writing defensive checks you shouldn't need.
Start small. Pick one pattern from this guide (discriminated unions or branded types are good first picks), use it in your current project, and see which bugs it catches. Then add another. Pretty soon you won't want to write TypeScript without them.
The type system isn't a tax on productivity. It's a sharp tool, sometimes a frustrating one, and once you know how to use it, it catches bugs before your users do, records your intent better than comments, and makes refactoring feel safe.
Anurag Sinha
Founder & Editor
Founder and editor of Tech Tips India. Writes tech guides, buying guides and explainers for Indian readers, with rupee pricing and local availability. Product roundups are researched from manufacturer specs and published reviews, not lab testing.
Stay Ahead in Tech
Get the latest tech news, tutorials, and reviews delivered straight to your inbox every week.
No spam ever. Unsubscribe anytime.
Comments (0)
Leave a Comment
All comments are moderated before appearing. Please be respectful and follow our community guidelines.
Related Articles

API Design: REST and GraphQL Patterns That Scale
API design guide covering REST, GraphQL, tRPC, authentication, rate limiting, error handling, and testing with Node.js examples.

DSA Roadmap for Placements: What Matters in 2026
Practical DSA roadmap for campus placements in India: topic priorities, platform choices, company patterns, and time management.

Redis Caching: Speed Up Your App in Practice
Redis caching strategies, data structures, and cache invalidation with Node.js, Next.js, and Upstash integration guide.