Back to Blog

Advanced TypeScript Patterns: Discriminated Unions, Branded Types, and Type Predicates

Level up your TypeScript — discriminated unions for exhaustive pattern matching, branded types for type-safe IDs, template literal types, type predicates, condi

Viprasol Tech Team
June 8, 2026
12 min read

Advanced TypeScript Patterns: Discriminated Unions, Branded Types, and Type Predicates

TypeScript's type system is powerful enough to encode complex domain constraints at compile time — preventing entire classes of bugs without runtime checks. These patterns go beyond basic interfaces and enums into the techniques that make large codebases genuinely maintainable.


Discriminated Unions

A discriminated union is a union of types that share a common literal "discriminant" field. TypeScript narrows the type based on the discriminant value.

// Basic discriminated union
type PaymentResult =
  | { status: 'success'; transactionId: string; amountCents: number }
  | { status: 'declined'; reason: string; code: string }
  | { status: 'pending'; checkBackAfter: Date };

function handlePayment(result: PaymentResult): string {
  switch (result.status) {
    case 'success':
      // TypeScript knows: result.transactionId exists here
      return `Payment ${result.transactionId} succeeded`;
    case 'declined':
      // TypeScript knows: result.reason and result.code exist here
      return `Declined: ${result.reason} (${result.code})`;
    case 'pending':
      // TypeScript knows: result.checkBackAfter exists here
      return `Pending until ${result.checkBackAfter.toISOString()}`;
  }
  // No default needed — TypeScript verifies all cases are handled (exhaustive check)
}

Exhaustive checking with never:

// If you add a new variant and forget to handle it, TypeScript will error
function assertNever(x: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}

type Notification =
  | { type: 'email'; to: string; subject: string }
  | { type: 'sms'; phone: string; message: string }
  | { type: 'push'; deviceToken: string; title: string };

function sendNotification(notif: Notification): void {
  switch (notif.type) {
    case 'email':
      sendEmail(notif.to, notif.subject);
      break;
    case 'sms':
      sendSMS(notif.phone, notif.message);
      break;
    case 'push':
      sendPush(notif.deviceToken, notif.title);
      break;
    default:
      // If you add 'webhook' to the union, this line errors until you handle it
      assertNever(notif);
  }
}

Discriminated unions for Result types:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function fetchUser(id: string): Promise<Result<User, 'not-found' | 'unauthorized'>> {
  const user = await db.users.findById(id);
  if (!user) return { ok: false, error: 'not-found' };
  return { ok: true, value: user };
}

const result = await fetchUser('123');
if (!result.ok) {
  // TypeScript knows: result.error is 'not-found' | 'unauthorized'
  if (result.error === 'not-found') return reply.code(404).send();
  return reply.code(403).send();
}
// TypeScript knows: result.value is User
console.log(result.value.email);

Branded Types

TypeScript's structural type system means string is string everywhere — a UserId and an OrderId are both just string. Branded types add a nominal layer:

// Brand utility type
type Brand<T, B extends string> = T & { readonly __brand: B };

// Domain-specific ID types
type UserId = Brand<string, 'UserId'>;
type TenantId = Brand<string, 'TenantId'>;
type OrderId = Brand<string, 'OrderId'>;

// Constructors that validate and brand
function UserId(id: string): UserId {
  if (!id.match(/^[0-9a-f-]{36}$/)) throw new Error(`Invalid UserId: ${id}`);
  return id as UserId;
}

// Now the type system prevents accidental ID mix-ups
async function getOrder(orderId: OrderId, userId: UserId): Promise<Order | null> {
  return db.orders.findFirst({
    where: { id: orderId, userId: userId },
  });
}

// TypeScript error: Argument of type 'UserId' is not assignable to type 'OrderId'
const user = UserId('abc-123');
await getOrder(user, user);  // ❌ Type error: UserId passed as OrderId

// Correct usage
const orderId = OrderId('def-456');
const userId = UserId('abc-123');
await getOrder(orderId, userId);  // ✅

Branded types for validated strings:

type Email = Brand<string, 'Email'>;
type HttpsUrl = Brand<string, 'HttpsUrl'>;
type PositiveCents = Brand<number, 'PositiveCents'>;

function parseEmail(input: string): Email {
  const cleaned = input.trim().toLowerCase();
  if (!cleaned.includes('@') || !cleaned.includes('.')) {
    throw new Error(`Invalid email: ${input}`);
  }
  return cleaned as Email;
}

function parseHttpsUrl(input: string): HttpsUrl {
  const url = new URL(input);  // Throws if invalid
  if (url.protocol !== 'https:') throw new Error('URL must use HTTPS');
  return input as HttpsUrl;
}

function parsePositiveCents(n: number): PositiveCents {
  if (!Number.isInteger(n) || n <= 0) {
    throw new Error(`Must be a positive integer: ${n}`);
  }
  return n as PositiveCents;
}

// Functions can require validated types, preventing unvalidated input
async function createCharge(email: Email, amountCents: PositiveCents): Promise<void> {
  // ...
}

// ✅ Validated at parse time — safe to use throughout
const email = parseEmail(req.body.email);
const amount = parsePositiveCents(req.body.amountCents);
await createCharge(email, amount);

// ❌ Type error: raw string not assignable to Email
await createCharge(req.body.email, amount);

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript — production-grade stack
  • Fixed-price contracts — no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Template Literal Types

Template literal types let you construct string types with compile-time constraints:

// Event name typing for an event bus
type EventName = `${string}:${string}`;  // namespace:action format

type UserEventName = `user:${'created' | 'updated' | 'deleted' | 'logged_in'}`;
type OrderEventName = `order:${'created' | 'paid' | 'shipped' | 'cancelled'}`;

type AppEvent = UserEventName | OrderEventName;

// Typed event bus
const eventBus = {
  emit<E extends AppEvent>(event: E, payload: EventPayload<E>): void { /* ... */ },
  on<E extends AppEvent>(event: E, handler: (payload: EventPayload<E>) => void): void { /* ... */ },
};

// CSS property builder
type CSSUnit = 'px' | 'rem' | 'em' | '%' | 'vh' | 'vw';
type CSSValue = `${number}${CSSUnit}`;

function spacing(value: CSSValue): string { return value; }
spacing('16px');   // ✅
spacing('1.5rem'); // ✅
spacing('100%');   // ✅
spacing('16');     // ❌ TypeScript error: missing unit

// API route typing
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type ApiRoute = `/${string}`;
type RouteKey = `${HttpMethod} ${ApiRoute}`;

type RouteHandlers = {
  [K in RouteKey]?: RequestHandler;
};

Type Predicates

Type predicates let you narrow types in conditional checks:

// User-defined type guard
function isError(value: unknown): value is Error {
  return value instanceof Error;
}

function isString(value: unknown): value is string {
  return typeof value === 'string';
}

// Discriminated union narrowing with predicate
type ApiResponse<T> =
  | { success: true; data: T }
  | { success: false; error: string };

function isSuccess<T>(response: ApiResponse<T>): response is { success: true; data: T } {
  return response.success === true;
}

const response = await fetchData();
if (isSuccess(response)) {
  // TypeScript narrows: response.data is available here
  processData(response.data);
}

// Array filter with type narrowing
const mixedValues: Array<string | number | null> = ['a', 1, null, 'b', null, 2];

// Without predicate: TypeScript infers (string | number | null)[]
const filtered = mixedValues.filter(v => v !== null);

// With predicate: TypeScript infers (string | number)[]
function isNonNull<T>(value: T | null | undefined): value is T {
  return value !== null && value !== undefined;
}
const typed = mixedValues.filter(isNonNull);
// typed: (string | number)[]

🚀 Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

The satisfies Operator

satisfies validates a value against a type without widening it — you get both type checking and the narrowed literal types:

type Route = {
  path: string;
  component: React.ComponentType;
  auth?: boolean;
};

// Without satisfies: TypeScript widens types
const routes = {
  home: { path: '/', component: HomePage },
  settings: { path: '/settings', component: SettingsPage, auth: true },
};
routes.home.path;  // string (widened)

// With satisfies: validates structure AND keeps literal types
const routes2 = {
  home: { path: '/', component: HomePage },
  settings: { path: '/settings', component: SettingsPage, auth: true },
} satisfies Record<string, Route>;

routes2.home.path;  // '/' (literal type preserved)

// Configuration objects benefit from satisfies
const config = {
  api: { baseUrl: 'https://api.example.com', timeout: 5000 },
  db: { host: 'localhost', port: 5432 },
} satisfies {
  api: { baseUrl: string; timeout: number };
  db: { host: string; port: number };
};

// TypeScript errors if you add an unknown key or wrong type
// but preserves exact literal types for autocomplete

Conditional Types for Generic Utilities

// Unwrap Promise type
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;

// Deep readonly
type DeepReadonly<T> = T extends (infer U)[]
  ? ReadonlyArray<DeepReadonly<U>>
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T;

// Extract function return type with async unwrapping
type AsyncReturnType<T extends (...args: any) => Promise<any>> =
  T extends (...args: any) => Promise<infer R> ? R : never;

async function fetchUser(id: string): Promise<{ id: string; email: string }> { /* ... */ }
type FetchedUser = AsyncReturnType<typeof fetchUser>;
// FetchedUser = { id: string; email: string }

// Require at least one of a set of keys
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
  Pick<T, Exclude<keyof T, Keys>> &
  { [K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>> }[Keys];

type SearchParams = RequireAtLeastOne<{
  userId?: string;
  email?: string;
  tenantId?: string;
}>;

const params: SearchParams = {};  // ❌ Error: must have at least one
const params2: SearchParams = { userId: '123' };  // ✅

Working With Viprasol

We build TypeScript codebases with strong type safety — discriminated unions for domain modeling, branded types for ID safety, generated types from OpenAPI and Prisma schemas. Good TypeScript prevents bugs before they reach production.

Talk to our team about TypeScript architecture and engineering practices.


See Also

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms — we build it all with Next.js and React.

Free consultation • No commitment • Response within 24 hours

Viprasol · Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.