TypeScript's type system is Turing-complete. That means you can encode arbitrarily complex logic at the type level. But with great power comes great responsibility. Here are patterns I use daily in production codebases.
Branded Types for Domain Safety
Primitive obsession is a common anti-pattern. A string could be a user ID, an email, or a URL — but the type system can't tell them apart. Branded types fix this:
// Create a unique brand symbol
declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [__brand]: B };
// Define domain types
type UserId = Brand<string, "UserId">;
type Email = Brand<string, "Email">;
type URL = Brand<string, "URL">;
// Smart constructors with validation
function createUserId(id: string): UserId {
if (!/^usr_[a-zA-Z0-9]{24}$/.test(id)) {
throw new Error(`Invalid user ID format: ${id}`);
}
return id as UserId;
}
function createEmail(email: string): Email {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new Error(`Invalid email: ${email}`);
}
return email as Email;
}
// Now these are type-safe — can't mix them up!
function getUserById(id: UserId): Promise<User> { /* ... */ }
function sendEmail(to: Email, subject: string): void { /* ... */ }
// This would be a compile-time error:
// getUserById(someEmail); // Type 'Email' is not assignable to type 'UserId'Template Literal Types for API Routes
TypeScript 4.1 introduced template literal types. Combined with mapped types, you can create type-safe API clients:
type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE";
type APIRoutes = {
"GET /users": { response: User[]; query: { limit?: number } };
"GET /users/:id": { response: User; params: { id: string } };
"POST /users": { response: User; body: CreateUserDTO };
"PUT /users/:id": { response: User; params: { id: string }; body: UpdateUserDTO };
"DELETE /users/:id": { response: void; params: { id: string } };
};
// Extract route parameters from path
type ExtractParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractParams<Rest>]: string }
: T extends `${string}:${infer Param}`
? { [K in Param]: string }
: never;
// Type-safe fetch wrapper
type RouteKey = keyof APIRoutes;
async function apiCall<K extends RouteKey>(
route: K,
config: Omit<APIRoutes[K], "response">
): Promise<APIRoutes[K]["response"]> {
const [method, path] = route.split(" ") as [HTTPMethod, string];
// Implementation here...
return {} as APIRoutes[K]["response"];
}
// Usage — fully type-safe!
const users = await apiCall("GET /users", { query: { limit: 10 } });
const user = await apiCall("GET /users/:id", { params: { id: "123" } });Conditional Types for Polymorphic Components
React components that change their props based on a discriminator are common. Here's how to type them correctly:
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
type ButtonBaseProps = {
variant?: ButtonVariant;
size?: "sm" | "md" | "lg";
disabled?: boolean;
children: React.ReactNode;
};
// Polymorphic "as" prop
type AsProps<E extends React.ElementType> = {
as?: E;
} & Omit<React.ComponentPropsWithoutRef<E>, keyof ButtonBaseProps>;
type ButtonProps<E extends React.ElementType = "button"> =
ButtonBaseProps & AsProps<E>;
function Button<E extends React.ElementType = "button">({
as,
variant = "primary",
size = "md",
children,
...props
}: ButtonProps<E>) {
const Component = as || "button";
return <Component className={`btn btn-${variant} btn-${size}`} {...props}>{children}</Component>;
}
// Works as button
<Button onClick={() => {}}>Click me</Button>
// Works as link with full anchor props
<Button as="a" href="/dashboard">Go to Dashboard</Button>
// Works as Next.js Link
<Button as={Link} href="/dashboard">Navigate</Button>The Builder Pattern with Method Chaining
For complex object construction, the builder pattern with type-safe method chaining ensures you can't forget required fields:
type RequiredFields = "name" | "email" | "role";
type BuilderState = {
[K in RequiredFields]: boolean;
};
class UserBuilder<State extends Partial<BuilderState> = {}> {
private data: Partial<User> = {};
name(name: string): UserBuilder<State & { name: true }> {
this.data.name = name;
return this as any;
}
email(email: string): UserBuilder<State & { email: true }> {
this.data.email = email;
return this as any;
}
role(role: Role): UserBuilder<State & { role: true }> {
this.data.role = role;
return this as any;
}
// build() is only available when ALL required fields are set
build(
this: UserBuilder<{ [K in RequiredFields]: true }>
): User {
return this.data as User;
}
}
// This works:
const user = new UserBuilder()
.name("Mukesh")
.email("mukesh@example.com")
.role("admin")
.build(); // OK!
// This fails at compile time:
// new UserBuilder().name("Mukesh").build();
// Error: Property 'build' does not existExhaustive Pattern Matching
Ensure you handle every case in a discriminated union:
type Result<T, E = Error> =
| { status: "success"; data: T }
| { status: "error"; error: E }
| { status: "loading" }
| { status: "idle" };
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
function renderResult<T>(result: Result<T>): string {
switch (result.status) {
case "success": return `Data: ${result.data}`;
case "error": return `Error: ${result.error.message}`;
case "loading": return "Loading...";
case "idle": return "Ready";
default: return assertNever(result);
// If you add a new status, TypeScript will error here
}
}Conclusion
These patterns might seem complex in isolation, but they pay dividends at scale. When your codebase grows to hundreds of files and dozens of contributors, the compiler becomes your most reliable code reviewer.
The best TypeScript code reads like documentation and fails like a safety net.
Type-level programming isn't about showing off — it's about making invalid states unrepresentable.