Skip to main content

TypeScript Advanced Patterns You Should Know

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

Priya Patel
19 min read
TypeScript Advanced Patterns You Should Know

The Type Error That Made Me Rethink Everything

Two years into writing TypeScript professionally, I hit a wall. Not a skill wall — a frustration wall. My code compiled fine. Type annotations were everywhere. Generics? Sure, I used them. Interfaces? Of course. The compiler was happy with me, and I was happy with the compiler. We had a nice little arrangement.

Then a production bug slipped through that shouldn't have been possible. A function expected a userId string but received a postId string. Both were just string. TypeScript had no opinion on the matter. Why would it? A string is a string. Except when it isn't.

That bug cost us four hours of debugging, a rollback, and an uncomfortable Slack thread. And the fix? Three lines of code and a pattern called "branded types" that I'd never heard of.

That's when I realized I'd been using TypeScript like training wheels instead of a jet engine. I knew enough to make the compiler quiet, but not enough to make it actually catch real bugs. Most TypeScript developers reach this same plateau. They understand interfaces, generics, and basic type annotations. They can make the compiler happy. But they never push further into the type system's real power — the patterns that eliminate entire categories of bugs at compile time, make APIs impossible to misuse, and turn the type checker into an active development partner rather than a nagging syntax enforcer.

After that incident, I started learning the advanced patterns. My runtime errors dropped dramatically. Not because I became a better programmer, but because the compiler was catching mistakes I used to ship.

These are the patterns I wish someone had shown me earlier. Every example comes from real production code (simplified for clarity), and every pattern solves a concrete problem you've probably encountered.


Discriminated Unions: Making Illegal States Unrepresentable

Discriminated unions are, I think, the single most useful advanced pattern in TypeScript. They let you model data that can be one of several shapes, with the compiler enforcing correct handling of each shape.

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. That's bad. Really bad.

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 dealing with. Inside each case, the compiler narrows the type automatically. You can't access response.data in the 'error' case because it doesn't exist on that variant. Try it. The compiler won't let you.

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...`;
  }
}

Every possible payment state is modeled with exactly the data it needs. No more checking if transactionId exists before using it. No more wondering if redirectUrl is available. The type system handles all of that for you.


Template Literal Types: String-Level Type Safety

Template literal types let you create types from string patterns. Extraordinarily useful for API routes, CSS values, event names, and any scenario where strings follow a predictable pattern.

// 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

Seems like overkill until you've caught a typo in a route string at compile time instead of discovering it in production at 2 AM. Then it seems like common sense.

Practical Application: 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: Types That Think

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. They enable type-level logic that might feel weird at first but becomes second nature.

// 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, which enables powerful filtering:

// 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, don't worry. Keep reading — the practical applications become clearer when 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, letting you extract inner types from complex structures. It's like 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. Once you internalize the pattern, you start seeing extraction opportunities everywhere — pulling return types out of hooks, grabbing event payload types from emitters, unwrapping deeply nested generics.


Branded Types: Type-Safe IDs

Here's the pattern that would've prevented my production bug. One of the most common runtime mistakes is passing the wrong ID to a function. A userId and a postId are both strings, so TypeScript can't distinguish them. Branded types fix this.

// 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 compile-time-only marker. Zero-cost abstraction that prevents an entire class of bugs where you accidentally pass a post ID where a user ID was expected. Three lines of type definition, and a whole category of mistakes becomes impossible.

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
}

Validation happens once at the boundary, and the branded type carries that guarantee through the rest of your code. No defensive re-validation deep in your call stack. No "just in case" checks scattered everywhere.


Builder Pattern with Types

Builder pattern combined with TypeScript's type system can ensure that required steps are completed before building an object. Sounds academic. It's incredibly 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 tracks which methods have been called, and execute() requires specific methods in its this type. Compiler prevents you from executing an incomplete query. You literally can't misuse the API if you tried.

I think this pattern's underused in the TypeScript community. Probably because it looks intimidating in the type definitions. But users of the API don't need to understand the implementation — they just get helpful error messages when they forget 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);
  }
}

Here's why this matters. If you later add a new shape variant (say 'pentagon'), the assertNever call will cause a compile error because shape in the default case is no longer never — it could be the new unhandled variant. Forces you to add handling for every new case. No more silent bugs from forgotten switch cases.

I've seen codebases where someone added a new status to an enum and forgot to update three switch statements. Took weeks for the bug to surface in production. With exhaustive checking, the compiler would've caught it immediately.


Zod for Runtime Validation with Type Inference

TypeScript types vanish at runtime. Gone. Completely. Zod bridges that gap by providing runtime validation that automatically generates TypeScript types.

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
}

Schema definition serves double duty: it validates data at runtime AND generates the TypeScript type. No more maintaining separate interfaces and validation logic that can drift apart. If you're building a full-stack app with Next.js, Zod pairs beautifully with Server Actions and API routes for end-to-end type safety.

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 Types Systematically

Mapped types let you create new types by transforming every property of an existing type. Think of them as Array.map() but for type properties.

// 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

These aren't just academic exercises. Every one of these utility types shows up regularly in real codebases. Pick and Omit are probably the most common — you'll use them whenever you need a subset of an existing type for a specific function or component.


Type-Safe Event Emitter

Combining generics with mapped types creates 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', () => {});

Every event name, callback parameter, and emit payload is fully typed. Can't listen for an event that doesn't exist, and you can't emit an event with the wrong data shape. The compiler catches all of it.


Generic Constraints: Narrowing the Possible

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 practical. Without them, you can't safely access any properties on a generic type. With them, you get both flexibility and safety.


Putting It All Together

Here's a real-world example that combines multiple patterns — a type-safe API client:

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']>>;
};

Every request body is validated, every response is typed, and the compiler ensures you call each endpoint correctly. That's the kind of type safety that makes TypeScript worth the investment.

These patterns are especially powerful when building modern frontend applications. If you're deciding between frameworks, our React vs Vue vs Svelte comparison explores how TypeScript support varies across them. And if you're designing APIs, understanding these type patterns is what you need for building type-safe API contracts, particularly with tRPC.


Types as Documentation

Here's what I've come to believe after years of writing TypeScript: the type system is your best documentation tool. Better than comments. Better than README files. Better than Confluence pages that nobody updates.

When you write a discriminated union, you're documenting every possible state your data can be in. When you write branded types, you're documenting that this string isn't just any string — it's a validated email or a specific kind of ID. When you write a builder pattern with type constraints, you're documenting the required order of operations. And unlike comments, types can't go stale. The compiler enforces them.

More thinking at write time for fewer bugs at runtime — that's a tradeoff I'll take every single time. It might slow you down for the first few days as you learn these patterns. After that, it speeds you up enormously, because you spend less time debugging, less time reading code to figure out what shape the data should be, and less time writing defensive checks that shouldn't be necessary.

Start small. Pick one pattern from this guide — I'd suggest discriminated unions or branded types — and apply it to your current project. See how it feels. Notice which bugs it catches. Then add another pattern. Before long, you won't be able to imagine writing TypeScript without them.

The type system isn't a tax on your productivity. It's a tool. A sharp, powerful, occasionally frustrating tool. But once you learn to wield it properly, it catches bugs before they reach your users, documents your intent better than comments ever could, and makes refactoring feel safe instead of terrifying. That's not a cost. That's a superpower.

Share

Priya Patel

Senior Tech Writer

AI and machine learning specialist with 6 years covering emerging technologies. Previously a senior tech correspondent at TechCrunch India, she now writes in-depth analyses of AI tools, LLM developments, and their real-world applications for Indian businesses.

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