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
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
- OpenAPI Design — generating TypeScript types from API specs
- Code Review Best Practices — TypeScript patterns in code review
- React State Management — TypeScript with Zustand and React Query
- Node.js API Development — TypeScript in production APIs
- Web Development Services — TypeScript engineering
About the Author
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.
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
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.