TypeScript Advanced Patterns in 2026: Type-Safe APIs, Branded Types, and Beyond
on Typescript, Javascript, Type safety, Software engineering, Frontend, Backend
TypeScript Advanced Patterns in 2026: Type-Safe APIs, Branded Types, and Beyond
TypeScript hit version 5.x and the type system has never been more powerful β or more misunderstood. Most teams use TypeScript as βJavaScript with autocomplete.β The engineers who unlock its real potential treat types as a first-class design tool that catches entire categories of bugs before the code runs.
This post covers the patterns that elite TypeScript teams actually use in production: not academic exercises, but real techniques that prevent real bugs.
Photo by Luca Bravo on Unsplash
1. Branded Types: Stop Mixing Up Your IDs
The most common runtime bug in large TypeScript codebases? Passing a userId where an orderId is expected. Both are string, so TypeScript allows it. Branded types fix this.
// Without branded types β TypeScript can't catch this bug
function getOrder(orderId: string): Order { ... }
function getUser(userId: string): User { ... }
const userId = "usr_123";
getOrder(userId); // TypeScript: π€· looks fine to me
// With branded types β caught at compile time
declare const __brand: unique symbol;
type Brand<T, B> = T & { [__brand]: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
function createUserId(id: string): UserId {
return id as UserId;
}
function createOrderId(id: string): OrderId {
return id as OrderId;
}
function getOrder(orderId: OrderId): Order { ... }
function getUser(userId: UserId): User { ... }
const userId = createUserId("usr_123");
getOrder(userId); // TypeScript: β Argument of type 'UserId' is not assignable to parameter of type 'OrderId'
The runtime cost is zero β brands only exist in the type system. The correctness gain is enormous.
Real-World Application: API Responses
type ProductionUrl = Brand<string, "ProductionUrl">;
type TestUrl = Brand<string, "TestUrl">;
function validateProductionUrl(url: string): ProductionUrl {
if (!url.startsWith("https://") || url.includes("localhost")) {
throw new Error("Not a valid production URL");
}
return url as ProductionUrl;
}
// Now your config types enforce environment safety
type ProductionConfig = {
apiUrl: ProductionUrl;
dbUrl: ProductionUrl;
};
2. Template Literal Types: Type-Safe String Manipulation
Template literal types let you model string patterns at the type level β routes, event names, CSS properties, and more.
// Type-safe event system
type EntityType = "user" | "order" | "product";
type EventAction = "created" | "updated" | "deleted";
type EventName = `${EntityType}:${EventAction}`;
// EventName = "user:created" | "user:updated" | "user:deleted"
// | "order:created" | "order:updated" | "order:deleted"
// | "product:created" | "product:updated" | "product:deleted"
type EventHandlerMap = {
[K in EventName]: (payload: EventPayload<K>) => void;
};
// Type-safe event emission
function emit<T extends EventName>(event: T, payload: EventPayload<T>): void {
// ...
}
emit("user:created", { id: "123", name: "Alice" }); // β
emit("user:exploded", { id: "123" }); // β Type error
Route Parameter Extraction
type ExtractRouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractRouteParams<`/${Rest}`>]: string }
: T extends `${string}:${infer Param}`
? { [K in Param]: string }
: {};
type UserRoute = ExtractRouteParams<"/users/:userId/orders/:orderId">;
// { userId: string; orderId: string }
function buildUrl<T extends string>(
template: T,
params: ExtractRouteParams<T>
): string {
return Object.entries(params).reduce(
(url, [key, value]) => url.replace(`:${key}`, value as string),
template
);
}
buildUrl("/users/:userId/orders/:orderId", { userId: "123", orderId: "456" }); // β
buildUrl("/users/:userId", { userId: "123", typo: "oops" }); // β Type error
3. Discriminated Unions: Model Your Domain Precisely
If youβre using null checks and ?. everywhere, youβre probably not using discriminated unions enough.
// β The "stringly typed" approach
type PaymentResult = {
status: string; // "success" | "failed" | "pending" β but TypeScript doesn't know that
amount?: number;
error?: string;
transactionId?: string;
};
// Always need to null-check everything:
if (result.status === "success" && result.transactionId) {
// Is amount guaranteed here? Maybe?
}
// β
Discriminated union β each state is explicit
type PaymentResult =
| { status: "success"; transactionId: string; amount: number }
| { status: "failed"; error: string; code: number }
| { status: "pending"; estimatedCompletionMs: number };
// TypeScript narrows perfectly:
function handlePayment(result: PaymentResult) {
switch (result.status) {
case "success":
console.log(result.transactionId); // β
Guaranteed to exist
break;
case "failed":
console.error(result.error, result.code); // β
Guaranteed to exist
break;
case "pending":
setTimeout(checkStatus, result.estimatedCompletionMs); // β
break;
}
}
Exhaustive Checking
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
function handlePayment(result: PaymentResult) {
switch (result.status) {
case "success": return processSuccess(result);
case "failed": return handleFailure(result);
case "pending": return waitForCompletion(result);
default:
return assertNever(result); // β Compile error if you add a new status and forget to handle it
}
}
4. Conditional Types and Infer: Advanced Type Transformations
// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer R> ? Awaited<R> : T;
// Extract function parameter types
type Parameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;
// Build a deep readonly type
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
// Deep partial (for patch operations)
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
Practical: Type-Safe API Client
type ApiSchema = {
"GET /users": { response: User[]; query: { limit?: number } };
"POST /users": { response: User; body: CreateUserRequest };
"GET /users/:id": { response: User; params: { id: string } };
};
type Method<T extends string> = T extends `${infer M} ${string}` ? M : never;
type Path<T extends string> = T extends `${string} ${infer P}` ? P : never;
async function apiCall<T extends keyof ApiSchema>(
endpoint: T,
options: Omit<ApiSchema[T], "response">
): Promise<ApiSchema[T]["response"]> {
// implementation
}
// Now fully type-safe:
const users = await apiCall("GET /users", { query: { limit: 10 } });
// users: User[]
const user = await apiCall("POST /users", { body: { name: "Alice", email: "alice@example.com" } });
// user: User
5. The satisfies Operator: Best of Both Worlds
Introduced in TypeScript 4.9 and now widely used, satisfies validates that a value matches a type without widening it.
type Colors = "red" | "green" | "blue";
type ColorMap = Record<Colors, string | [number, number, number]>;
// With `as ColorMap` β TypeScript widens the type, losing specifics
const palette1 = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255],
} as ColorMap;
palette1.red; // string | [number, number, number] β lost the tuple info!
// With `satisfies` β validates the type but keeps the inferred type
const palette2 = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255],
} satisfies ColorMap;
palette2.red; // [number, number, number] β TypeScript knows it's a tuple!
palette2.red.map(c => c / 255); // β
Works because TypeScript knows it's an array
// Catches errors:
const badPalette = {
red: [255, 0, 0],
green: "#00ff00",
// missing blue β
} satisfies ColorMap;
6. Mapped Types with Key Remapping
// Create getter method names from property names
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type User = { name: string; age: number; email: string };
type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number; getEmail: () => string }
// Filter properties by type
type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K];
};
type StringProps = PickByType<User, string>;
// { name: string; email: string }
// Make specific properties required
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
type UserWithRequiredAge = RequiredBy<Partial<User>, "age">;
// { name?: string; age: number; email?: string }
7. Variance and Covariance: Understanding Type Compatibility
// Covariant (safe to widen) β return types
type Producer<T> = () => T;
type StringProducer = Producer<string>;
type UnknownProducer = Producer<unknown>;
const sp: StringProducer = () => "hello";
const up: UnknownProducer = sp; // β
β a string producer can always substitute for an unknown producer
// Contravariant (safe to narrow) β parameter types
type Consumer<T> = (value: T) => void;
type StringConsumer = Consumer<string>;
type UnknownConsumer = Consumer<unknown>;
const uc: UnknownConsumer = (v: unknown) => console.log(v);
const sc: StringConsumer = uc; // β
β a consumer of unknown can always handle a string
// This matters for callbacks:
type Callback<T> = (value: T) => void;
function processUsers(callback: Callback<User>): void { ... }
const logAnything: Callback<unknown> = (v) => console.log(v);
processUsers(logAnything); // β
Safe β works for any value including User
Project Structure for Scalable TypeScript
src/
βββ types/
β βββ branded.ts # All branded types
β βββ api.ts # API schema types
β βββ domain.ts # Domain model types (discriminated unions)
βββ utils/
β βββ type-guards.ts # Runtime type narrowing functions
β βββ type-helpers.ts # Utility type functions
βββ __tests__/
βββ types.test-d.ts # Type-level tests with tsd or expect-type
Type Testing with expect-type
import { expectTypeOf } from "expect-type";
// Tests that run at compile time, not runtime
expectTypeOf(createUserId("123")).toMatchTypeOf<UserId>();
expectTypeOf(buildUrl("/users/:id", { id: "123" })).toBeString();
expectTypeOf(palette2.red).toMatchTypeOf<[number, number, number]>();
The TypeScript in 2026 Mindset
The engineers writing the best TypeScript in 2026 follow a simple philosophy: if you can make the type system enforce it, donβt rely on documentation or code review to catch it.
Every any, every non-null assertion (!), every as SomeType cast is a place where youβve made a deal with the compiler: βtrust me on this one.β The goal is to minimize those deals β and when you make them, make them in one place, at the boundary of your system (parsing external data, interacting with untyped libraries), not scattered throughout your business logic.
Photo by Florian Olivo on Unsplash
The type system is not a bureaucracy to satisfy β itβs a tireless colleague who will check every callsite, every day, for free. Treat it like one.
Key Takeaways
- Branded types prevent ID confusion at zero runtime cost
- Template literal types model string patterns as first-class types
- Discriminated unions eliminate entire classes of null-check bugs
- Conditional types enable powerful type-level computation
satisfiesvalidates types without losing specificity- Mapped types with remapping transform type shapes programmatically
- Write type-level tests alongside your regular tests
The gap between TypeScript beginners and TypeScript experts isnβt knowing more syntax β itβs knowing how to use types as a design tool that makes illegal states unrepresentable.
μ΄ κΈμ΄ λμμ΄ λμ
¨λ€λ©΄ κ³΅κ° λ° κ΄κ³ ν΄λ¦μ λΆνλ립λλ€ :)
