TypeScript Advanced Patterns: Type-Safe APIs, Zod, and Runtime Validation
TypeScript Advanced Patterns: Type-Safe APIs, Zod, and Runtime Validation

Introduction
TypeScript's type system protects you from type errors at compile time. But your API receives JSON from untrusted clients at runtime, your environment variables are strings with no type information, and your database returns untyped rows. The gap between "TypeScript says this is a number" and "this is actually a number at runtime" is where production bugs live.
This post is about closing that gap. It covers the TypeScript patterns that experienced engineers use in production: Zod for runtime validation with type inference, tRPC for end-to-end type-safe APIs, discriminated unions for state machines, conditional types and template literal types for advanced type modeling, the satisfies operator (TypeScript 4.9+) for type checking without widening, and the patterns that make TypeScript codebases maintainable as they scale.
These are not beginner TypeScript topics. They assume familiarity with generics, interfaces, and basic TypeScript syntax. The goal is the patterns that separate production-grade TypeScript from "JavaScript with type annotations" — the patterns that make TypeScript's type system actually useful rather than ceremonial.
Zod: Runtime Validation with Compile-Time Type Inference
Zod is the most important TypeScript library to understand in 2026. It solves the runtime/compile-time gap by defining schemas that simultaneously validate data at runtime and infer TypeScript types at compile time — from a single definition.
import { z } from 'zod';
// Define once: both validation logic and TypeScript type
const CreateOrderSchema = z.object({
userId: z.string().uuid(),
items: z.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(100),
unitCents: z.number().int().positive(),
})
).min(1).max(50),
currency: z.enum(['USD', 'EUR', 'GBP']),
shippingAddress: z.object({
street: z.string().min(1).max(255),
city: z.string().min(1),
country: z.string().length(2), // ISO 3166-1 alpha-2
postalCode: z.string().regex(/^[A-Z0-9\s-]{3,10}$/),
}),
couponCode: z.string().optional(),
}).refine(
(data) => data.items.reduce((sum, item) => sum + item.quantity, 0) <= 100,
{ message: "Total item quantity cannot exceed 100" }
);
// TypeScript type derived from the schema — no duplicate definition
type CreateOrderRequest = z.infer<typeof CreateOrderSchema>;
// Equivalent to writing the full interface manually, but stays in sync automatically
// Usage in API handler
async function createOrder(rawBody: unknown): Promise<Order> {
// parse throws ZodError with detailed field-level errors on failure
const validated = CreateOrderSchema.parse(rawBody);
// validated is typed as CreateOrderRequest — TypeScript knows all fields are valid
return orderService.create(validated);
}
// Safe parse: returns result object instead of throwing
const result = CreateOrderSchema.safeParse(rawBody);
if (!result.success) {
// result.error.issues: array of field-level errors with paths
const errors = result.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
}));
return { error: 'Validation failed', details: errors };
}
const order = await createOrder(result.data);
The critical pattern: z.infer<typeof Schema> derives the TypeScript type from the Zod schema. The type and validation logic can never diverge — they're defined once. Manually written TypeScript interfaces are checked at compile time but not validated at runtime. Zod schemas are validated at runtime and type-checked at compile time.

tRPC: End-to-End Type-Safe APIs
tRPC eliminates the client-server type boundary entirely. Your API router's TypeScript types are directly available in the client — no code generation step, no OpenAPI spec, no manual type synchronization.
// server/router.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.context<{ userId: string | null }>().create();
const publicProcedure = t.procedure;
const authedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, userId: ctx.userId } }); // narrows type: userId not null
});
export const appRouter = t.router({
order: t.router({
create: authedProcedure
.input(CreateOrderSchema) // Zod schema for input validation
.output(OrderResponseSchema) // Zod schema for output validation
.mutation(async ({ ctx, input }) => {
// ctx.userId: string (not null — narrowed by middleware)
// input: CreateOrderRequest (fully typed)
return orderService.create({ ...input, userId: ctx.userId });
}),
getById: authedProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const order = await orderService.getById(input.id, ctx.userId);
if (!order) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
return order;
}),
list: authedProcedure
.input(z.object({
cursor: z.string().optional(),
limit: z.number().int().min(1).max(100).default(20),
status: z.enum(['pending', 'completed', 'cancelled']).optional(),
}))
.query(async ({ ctx, input }) => {
return orderService.list(ctx.userId, input);
}),
}),
});
// Export type for client use
export type AppRouter = typeof appRouter;
// client/orders.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router'; // type-only import
const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({ url: '/api/trpc' }),
],
});
// Fully typed: TypeScript knows the input shape and return type
// without any code generation or manual type definitions
const order = await trpc.order.create.mutate({
userId: 'abc', // TypeScript error: userId not in CreateOrderSchema
items: [{ productId: '...', quantity: 1, unitCents: 1000 }],
currency: 'USD',
shippingAddress: { street: '123 Main', city: 'NYC', country: 'US', postalCode: '10001' },
});
// order is typed as OrderResponse — TypeScript knows the return shape
// Cursor-based pagination: TypeScript knows cursor and limit are valid
const orders = await trpc.order.list.query({
cursor: order.id,
limit: 20,
status: 'pending',
});
tRPC is most valuable for monorepos where frontend and backend are developed together. For public APIs that need to be consumed by external clients, REST + OpenAPI is still the right choice. The decision is simple: internal full-stack TypeScript app → tRPC. External API → REST with OpenAPI.
Discriminated Unions: Type-Safe State Machines
TypeScript's discriminated unions are the most underused advanced feature. They model finite state machines — states that have different shapes and can only transition through defined paths.
// Order status as a discriminated union
type OrderState =
| { status: 'pending'; createdAt: Date }
| { status: 'confirmed'; confirmedAt: Date; paymentId: string }
| { status: 'shipped'; shippedAt: Date; trackingNumber: string }
| { status: 'delivered'; deliveredAt: Date }
| { status: 'cancelled'; cancelledAt: Date; reason: string };
// TypeScript narrows the type in switch/if based on the discriminant (status)
function formatOrderStatus(order: OrderState): string {
switch (order.status) {
case 'pending':
return `Order created ${order.createdAt.toISOString()}`;
case 'confirmed':
// TypeScript knows: order.paymentId exists here
return `Confirmed. Payment: ${order.paymentId}`;
case 'shipped':
// TypeScript knows: order.trackingNumber exists here
return `Shipped. Tracking: ${order.trackingNumber}`;
case 'delivered':
return `Delivered ${order.deliveredAt.toISOString()}`;
case 'cancelled':
// TypeScript knows: order.reason exists here
return `Cancelled: ${order.reason}`;
}
// TypeScript enforces exhaustiveness: if you add a new status to the union
// and forget to handle it here, you get a compile error (via never check)
}
// Exhaustiveness check pattern
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}
// Add to switch default:
// default: return assertNever(order);
// Now TypeScript will warn if any new union member is unhandled
The Zod equivalent for discriminated unions — validated at runtime:
const OrderStateSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('pending'), createdAt: z.date() }),
z.object({ status: z.literal('confirmed'), confirmedAt: z.date(), paymentId: z.string() }),
z.object({ status: z.literal('shipped'), shippedAt: z.date(), trackingNumber: z.string() }),
z.object({ status: z.literal('delivered'), deliveredAt: z.date() }),
z.object({ status: z.literal('cancelled'), cancelledAt: z.date(), reason: z.string() }),
]);
type OrderState = z.infer<typeof OrderStateSchema>;
z.discriminatedUnion performs a fast discriminant-based lookup (O(1) per status value) rather than trying each schema in sequence — important for performance when validating large arrays.
Advanced Types: Template Literal Types and Conditional Types
TypeScript 4.1+ template literal types enable string-level type checking:
// Template literal types: type-safe event names
type ServiceName = 'user' | 'order' | 'payment';
type EventVerb = 'created' | 'updated' | 'deleted';
// Type-safe event topic names: "user.created", "order.deleted", etc.
type KafkaTopic = `${ServiceName}.${EventVerb}`;
// Prevents typos in topic names at compile time
// Generics with template literals
type EventPayload<T extends KafkaTopic> =
T extends `user.${string}` ? { userId: string } :
T extends `order.${string}` ? { orderId: string; total: number } :
T extends `payment.${string}` ? { paymentId: string; amount: number } :
never;
// Usage: TypeScript knows the payload shape from the topic name
function publishEvent<T extends KafkaTopic>(
topic: T,
payload: EventPayload<T>
): void {
kafkaProducer.send({ topic, value: JSON.stringify(payload) });
}
publishEvent('order.created', { orderId: '123', total: 4999 }); // OK
publishEvent('order.created', { userId: '123' }); // TypeScript error: wrong payload type
publishEvent('invalid.event', { orderId: '123' }); // TypeScript error: invalid topic
Conditional types for mapped API responses:
// Result type: either a value or an error
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
// Utility: unwrap Result type
type Unwrap<T> = T extends Result<infer U> ? U : never;
// DeepPartial: recursively make all properties optional (useful for patches)
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
// Readonly recursive: prevent mutation of nested objects
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
Strict TypeScript Configuration
The TypeScript compiler has 50+ options. The production-correct tsconfig.json strict settings:
{
"compilerOptions": {
"strict": true, // enables all strict checks below
"noUncheckedIndexedAccess": true, // arr[0]: T | undefined (not T)
"exactOptionalPropertyTypes": true, // {a?: string} doesn't allow {a: undefined}
"noImplicitReturns": true, // all code paths must return
"noFallthroughCasesInSwitch": true, // switch cases must break or return
"allowUnreachableCode": false, // error on dead code
"noUnusedLocals": true, // error on unused variables
"noUnusedParameters": true, // error on unused parameters
"useUnknownInCatchVariables": true // catch (e: unknown) not (e: any)
}
}
noUncheckedIndexedAccess is the most impactful non-strict option. Without it, arr[0] returns T — TypeScript assumes the index is valid. With it, arr[0] returns T | undefined, requiring you to handle the empty case. This eliminates an entire class of "undefined is not a function" runtime errors from array and object indexing.
useUnknownInCatchVariables makes caught exceptions typed as unknown rather than any. This forces explicit type narrowing before accessing exception properties — preventing the common pattern of catch (e) { logger.error(e.message) } which crashes when e is not an Error.
The satisfies Operator: Type Checking Without Widening
Introduced in TypeScript 4.9, satisfies is the correct tool for configuration objects and constants where you want type checking but need to preserve the inferred type:
// Problem: 'as' assertion loses type information
const config = {
database: { host: 'localhost', port: 5432 },
redis: { host: 'localhost', port: 6379 },
} as Record<string, { host: string; port: number }>;
config.database.port // type: number (lost: doesn't know it's specifically 5432)
config.nonexistent // no error: Record allows any string key
// Solution: satisfies checks the type but preserves the specific inferred type
const config = {
database: { host: 'localhost', port: 5432 },
redis: { host: 'localhost', port: 6379 },
} satisfies Record<string, { host: string; port: number }>;
config.database.port // type: 5432 (literal type preserved)
config.nonexistent // TypeScript error: property doesn't exist
// Practical use: validated route handler map
const handlers = {
'/api/orders': handleOrders,
'/api/payments': handlePayments,
'/api/users': handleUsers,
} satisfies Record<string, RequestHandler>;
// Type check: all values must be RequestHandler
// Type preservation: TypeScript knows the exact keys
// Environment variable parsing with satisfies
const env = {
DATABASE_URL: process.env.DATABASE_URL ?? '',
REDIS_URL: process.env.REDIS_URL ?? '',
PORT: parseInt(process.env.PORT ?? '3000', 10),
} satisfies { DATABASE_URL: string; REDIS_URL: string; PORT: number };

Branded Types: Preventing Value Confusion
TypeScript's structural typing means string and string are interchangeable, even if one is a user ID and the other is an order ID. Branded types add nominal typing on top of structural typing:
// Brand: a type that looks like T but is nominally distinct
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type Cents = Brand<number, 'Cents'>;
// Constructor functions enforce the brand
const UserId = (id: string): UserId => id as UserId;
const OrderId = (id: string): OrderId => id as OrderId;
const Cents = (n: number): Cents => {
if (!Number.isInteger(n) || n < 0) {
throw new Error(`Invalid cents: ${n}`);
}
return n as Cents;
};
// Functions can now require specific branded types
function getOrder(userId: UserId, orderId: OrderId): Promise<Order> {
return db.query('SELECT * FROM orders WHERE user_id = $1 AND id = $2',
[userId, orderId]);
}
// TypeScript error: argument order matters — can't swap
const userId = UserId('user-123');
const orderId = OrderId('order-456');
getOrder(orderId, userId); // TypeScript error: wrong type in each position
getOrder(userId, orderId); // OK
// Prevents common bugs like:
// chargeCard(productId, userId) — swapped arguments, both string, no error without brands
Branded types eliminate an entire category of "passed arguments in wrong order" bugs that TypeScript's structural type system otherwise misses.
Generic Constraints and Utility Types
TypeScript's built-in utility types are frequently underused. Understanding them prevents reinventing common patterns:
// Partial: all properties optional (for update/patch operations)
function updateUser(id: string, updates: Partial<User>): Promise<User> {
return db.update('users', id, updates);
}
// Required: all properties required (overrides optional in interface)
type RequiredConfig = Required<AppConfig>;
// Pick and Omit: create derived types
type UserPublicProfile = Pick<User, 'id' | 'username' | 'avatarUrl'>;
type UserWithoutPassword = Omit<User, 'passwordHash' | 'saltRounds'>;
// Record: type-safe key-value maps
type StatusMessages = Record<OrderStatus, string>;
const messages: StatusMessages = {
pending: 'Your order is being processed',
confirmed: 'Your order is confirmed',
shipped: 'Your order is on the way',
delivered: 'Your order has been delivered',
cancelled: 'Your order has been cancelled',
};
// TypeScript error if any OrderStatus key is missing
// Extract and Exclude: filter union members
type SuccessStatuses = Extract<OrderStatus, 'confirmed' | 'shipped' | 'delivered'>;
type TerminalStatuses = Exclude<OrderStatus, 'pending' | 'confirmed' | 'shipped'>;
// TerminalStatuses = 'delivered' | 'cancelled'
// ReturnType and Parameters: derive types from functions
async function fetchOrder(id: string, userId: string): Promise<Order> { ... }
type FetchOrderReturn = Awaited<ReturnType<typeof fetchOrder>>; // Order
type FetchOrderArgs = Parameters<typeof fetchOrder>; // [string, string]
Generic constraints enforce that type parameters satisfy a contract:
// Constrain T to objects with an 'id' property
function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
// Works for any type with an id field
const user = findById(users, 'user-123'); // T inferred as User
const order = findById(orders, 'ord-456'); // T inferred as Order
// Generic repository pattern with constraints
interface Repository<T extends { id: string }> {
findById(id: string): Promise<T | null>;
findMany(filter: Partial<T>): Promise<T[]>;
create(data: Omit<T, 'id'>): Promise<T>;
update(id: string, data: Partial<Omit<T, 'id'>>): Promise<T>;
delete(id: string): Promise<void>;
}
class PostgresRepository<T extends { id: string }> implements Repository<T> {
constructor(private readonly table: string) {}
async findById(id: string): Promise<T | null> {
const rows = await db.query<T>(`SELECT * FROM ${this.table} WHERE id = $1`, [id]);
return rows[0] ?? null;
}
// ... other methods
}
// Reusable for any entity
const userRepo = new PostgresRepository<User>('users');
const orderRepo = new PostgresRepository<Order>('orders');
Type Guards: Narrowing at Runtime
Type guards allow TypeScript to narrow types inside conditional blocks:
// User-defined type guard
function isOrderConfirmed(order: OrderState): order is Extract<OrderState, { status: 'confirmed' }> {
return order.status === 'confirmed';
}
// Assertion function: throws if condition is false
function assertDefined<T>(value: T | null | undefined, message: string): asserts value is T {
if (value == null) {
throw new Error(message);
}
}
// Using assertion function
const user = await userRepo.findById(userId);
assertDefined(user, `User ${userId} not found`);
// After this line, TypeScript knows: user is User (not null)
user.email; // no TypeScript error
// Type narrowing with in operator
interface AdminUser extends User { adminLevel: number; permissions: string[] }
interface RegularUser extends User { subscriptionTier: string }
type AppUser = AdminUser | RegularUser;
function isAdmin(user: AppUser): user is AdminUser {
return 'adminLevel' in user;
}
function handleUser(user: AppUser) {
if (isAdmin(user)) {
// user: AdminUser — TypeScript knows adminLevel and permissions exist
console.log(`Admin level: ${user.adminLevel}`);
} else {
// user: RegularUser — TypeScript knows subscriptionTier exists
console.log(`Subscription: ${user.subscriptionTier}`);
}
}
Error Handling with Result Types
TypeScript doesn't enforce error handling. Thrown exceptions don't appear in function signatures. The Result pattern makes error handling explicit and type-safe:
type Result<T, E extends Error = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
class DatabaseError extends Error {
constructor(message: string, public readonly query?: string) {
super(message);
this.name = 'DatabaseError';
}
}
class ValidationError extends Error {
constructor(message: string, public readonly field: string) {
super(message);
this.name = 'ValidationError';
}
}
async function createUser(
data: CreateUserRequest
): Promise<Result<User, ValidationError | DatabaseError>> {
const validation = validateUser(data);
if (!validation.ok) {
return { ok: false, error: new ValidationError(validation.message, validation.field) };
}
try {
const user = await db.insert('users', data);
return { ok: true, value: user };
} catch (e) {
return { ok: false, error: new DatabaseError('Insert failed', 'INSERT INTO users') };
}
}
// Caller must handle both cases — TypeScript enforces this
const result = await createUser(data);
if (!result.ok) {
if (result.error instanceof ValidationError) {
return httpResponse(400, { field: result.error.field, message: result.error.message });
}
return httpResponse(500, { message: 'Internal error' });
}
// result.value: User — TypeScript knows this is only reachable when ok is true
return httpResponse(201, result.value);
Conclusion
TypeScript advanced patterns in 2026 form a coherent toolkit for production type safety: Zod closes the compile-time/runtime gap by deriving types from validated schemas. tRPC eliminates the client-server type boundary for full-stack TypeScript apps. Discriminated unions model finite state machines with compiler-enforced exhaustiveness. Branded types prevent value-swapping bugs. The satisfies operator type-checks configuration without losing specificity.
The theme across all of these patterns is pushing more invariants into the type system — making illegal states unrepresentable, making wrong argument orders type errors, making unhandled API errors compile errors. Every constraint enforced by TypeScript is a class of bugs that can't exist in production.
The patterns have a hierarchy by payoff:
1. Zod + inferred types — highest impact, eliminates the entire runtime/compile-time type gap
2. Discriminated unions — models state machines correctly, eliminates invalid state bugs
3. tRPC — eliminates all client-server type sync overhead for full-stack apps
4. Branded types — prevents value confusion (user ID vs order ID), highest value in large codebases
5. satisfies operator — tightens configuration objects, eliminates magic string access
6. Result types — makes error handling explicit and type-checked
Start with Zod on API boundaries. The rest are progressive enhancements as the codebase grows. A mature TypeScript codebase uses all of them — but Zod alone eliminates the majority of runtime type errors in API handlers.
Sources
- Zod Documentation
- tRPC Documentation
- TypeScript 4.9 —
satisfiesoperator - TypeScript Handbook — Conditional Types
Enjoyed this post? Follow AmtocSoft for AI tutorials from beginner to professional.
☕ Buy Me a Coffee | 🔔 YouTube | 💼 LinkedIn | 🐦 X/Twitter
Comments
Post a Comment