Advanced TypeScript Patterns
Production-grade TypeScript patterns for building type-safe, maintainable, and scalable applications. This skill covers generics, utility types, conditional types, discriminated unions, branded types, and more.
1. Advanced Generics with Constraints
Basic Generic Constraints
// Constrain T to objects that have a .length property
function getLength<T extends { length: number }>(item: T): number {
return item.length;
}
getLength("hello"); // OK - string has .length
getLength([1, 2, 3]); // OK - array has .length
// getLength(42); // Error - number has no .length
Multiple Constraints with Intersection
interface HasId {
id: string;
}
interface HasTimestamp {
createdAt: Date;
updatedAt: Date;
}
// T must satisfy both interfaces
function updateEntity<T extends HasId & HasTimestamp>(
entity: T,
updates: Partial<Omit<T, "id" | "createdAt">>
): T {
return { ...entity, ...updates, updatedAt: new Date() };
}
keyof Constraint Pattern
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30, email: "alice@example.com" };
const name = getProperty(user, "name"); // type: string
const age = getProperty(user, "age"); // type: number
// getProperty(user, "phone"); // Error: "phone" not in keyof user
Generic Factory Pattern
interface Constructor<T> {
new (...args: any[]): T;
}
function createInstance<T>(ctor: Constructor<T>, ...args: any[]): T {
return new ctor(...args);
}
class UserService {
constructor(public apiUrl: string) {}
}
const service = createInstance(UserService, "https://api.example.com");
// service is typed as UserService
2. Built-in Utility Types
Pick, Omit, Partial, Required
interface User {
id: string;
name: string;
email: string;
avatar?: string;
role: "admin" | "user" | "moderator";
preferences: {
theme: "light" | "dark";
notifications: boolean;
};
}
// Pick only the fields you need
type UserSummary = Pick<User, "id" | "name" | "avatar">;
// Omit sensitive fields
type PublicUser = Omit<User, "email" | "preferences">;
// Make all fields optional (useful for update payloads)
type UserUpdate = Partial<Omit<User, "id">>;
// Make optional fields required
type CompleteUser = Required<User>;
// avatar is now required
Record, Extract, Exclude
// Record: create an object type with known keys
type UserRole = "admin" | "user" | "moderator";
type RolePermissions = Record<UserRole, string[]>;
const permissions: RolePermissions = {
admin: ["read", "write", "delete", "manage-users"],
user: ["read", "write"],
moderator: ["read", "write", "delete"],
};
// Extract: pull types from a union that match
type NumericEvent = Extract<"click" | "scroll" | "keypress" | 42, string>;
// Result: "click" | "scroll" | "keypress"
// Exclude: remove types from a union
type NonAdminRole = Exclude<UserRole, "admin">;
// Result: "user" | "moderator"
ReturnType, Parameters, InstanceType
function fetchUser(id: string, options?: { includeProfile: boolean }) {
return { id, name: "Alice", email: "alice@example.com" };
}
type FetchUserReturn = ReturnType<typeof fetchUser>;
// { id: string; name: string; email: string }
type FetchUserParams = Parameters<typeof fetchUser>;
// [id: string, options?: { includeProfile: boolean }]
class ApiClient {
baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
}
type ApiClientInstance = InstanceType<typeof ApiClient>;
// ApiClient
3. Conditional Types and the infer Keyword
Basic Conditional Types
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
Extracting Types with infer
// Extract the return type of a promise
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type Result = UnwrapPromise<Promise<string>>; // string
type Plain = UnwrapPromise<number>; // number
// Extract element type from an array
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Item = ElementOf<string[]>; // string
type Nested = ElementOf<number[]>; // number
// Extract function arguments
type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type Arg = FirstArg<(name: string, age: number) => void>; // string
Distributive Conditional Types
// Conditional types distribute over unions automatically
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// string[] | number[] (NOT (string | number)[])
// Prevent distribution with wrapping in tuple
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;
type Result2 = ToArrayNonDist<string | number>;
// (string | number)[]
Recursive Conditional Types
// Deeply unwrap nested promises
type DeepUnwrap<T> = T extends Promise<infer U> ? DeepUnwrap<U> : T;
type Deep = DeepUnwrap<Promise<Promise<Promise<string>>>>; // string
// Deep partial - make all nested properties optional
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
interface Config {
server: {
host: string;
port: number;
ssl: {
enabled: boolean;
cert: string;
};
};
}
type PartialConfig = DeepPartial<Config>;
// All nested fields are now optional
4. Mapped Types and Template Literal Types
Custom Mapped Types
// Make all properties readonly
type Immutable<T> = {
readonly [K in keyof T]: T[K] extends object ? Immutable<T[K]> : T[K];
};
// Make all properties nullable
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
// Add a prefix to all keys
type Prefixed<T, P extends string> = {
[K in keyof T as `${P}${Capitalize<string & K>}`]: T[K];
};
interface FormData {
name: string;
email: string;
}
type "onChange">;
// { onChangeName: string; onChangeEmail: string }
Template Literal Types
type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type APIVersion = "v1" | "v2";
type Resource = "users" | "posts" | "comments";
// Generate all route combinations
type APIRoute = `/${APIVersion}/${Resource}`;
// "/v1/users" | "/v1/posts" | "/v1/comments" | "/v2/users" | ...
// Event handler types
type DOMEvent = "click" | "focus" | "blur" | "input";
type EventHandler = `on${Capitalize<DOMEvent>}`;
// "onClick" | "onFocus" | "onBlur" | "onInput"
// CSS unit types
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
const width: CSSValue = "100px"; // OK
const height: CSSValue = "50vh"; // OK
// const bad: CSSValue = "auto"; // Error
Key Remapping with as
// Create getter functions for each property
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person {
name: string;
age: number;
}
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }
// Filter keys by value type
type StringKeysOnly<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
interface Mixed {
name: string;
age: number;
email: string;
active: boolean;
}
type
// { name: string; email: string }
5. Discriminated Unions and Exhaustive Checking
Basic Discriminated Union
interface LoadingState {
status: "loading";
}
interface SuccessState<T> {
status: "success";
data: T;
}
interface ErrorState {
status: "error";
error: {
message: string;
code: number;
};
}
type RequestState<T> = LoadingState | SuccessState<T> | ErrorState;
function renderState<T>(state: RequestState<T>): string {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return `Data: ${JSON.stringify(state.data)}`;
case "error":
return `Error ${state.error.code}: ${state.error.message}`;
}
}
Exhaustive Checking with never
// This function ensures all union variants are handled at compile time
function assertNever(value: never): never {
throw new Error(`Unhandled discriminated union member: ${JSON.stringify(value)}`);
}
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
default:
// If a new variant is added to Shape but not handled here,
// TypeScript will produce a compile error on this line.
return assertNever(shape);
}
}
Action/Event Pattern (Redux-style)
type Action =
| { type: "ADD_TODO"; payload: { text: string } }
| { type: "TOGGLE_TODO"; payload: { id: number } }
| { type: "REMOVE_TODO"; payload: { id: number } }
| { type: "SET_FILTER"; payload: { filter: "all" | "active" | "completed" } };
interface TodoState {
todos: Array<{ id: number; text: string; completed: boolean }>;
filter: "all" | "active" | "completed";
}
function todoReducer(state: TodoState, action: Action): TodoState {
switch (action.type) {
case "ADD_TODO":
return {
...state,
todos: [
...state.todos,
{ id: Date.now(), text: action.payload.text, completed: false },
],
};
case "TOGGLE_TODO":
return {
...state,
todos: state.todos.map((t) =>
t.id === action.payload.id ? { ...t, completed: !t.completed } : t
),
};
case "REMOVE_TODO":
return {
...state,
todos: state.todos.filter((t) => t.id !== action.payload.id),
};
case "SET_FILTER":
return { ...state, filter: action.payload.filter };
default:
return assertNever(action as never);
}
}
6. Type Guards and Type Narrowing
Custom Type Guard Functions
interface Cat {
type: "cat";
purrs: boolean;
}
interface Dog {
type: "dog";
barks: boolean;
}
type Animal = Cat | Dog;
// Type predicate: `animal is Cat`
function isCat(animal: Animal): animal is Cat {
return animal.type === "cat";
}
function handleAnimal(animal: Animal) {
if (isCat(animal)) {
console.log(animal.purrs); // TypeScript knows this is Cat
} else {
console.log(animal.barks); // TypeScript knows this is Dog
}
}
Assertion Functions
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error(`Expected string, got ${typeof value}`);
}
}
function processInput(input: unknown) {
assertIsString(input);
// After assertion, TypeScript knows input is string
console.log(input.toUpperCase());
}
in Operator Narrowing
interface APIResponse {
data: unknown;
}
interface APIError {
error: string;
statusCode: number;
}
function handleResponse(res: APIResponse | APIError) {
if ("error" in res) {
// TypeScript narrows to APIError
console.error(`Error ${res.statusCode}: ${res.error}`);
} else {
// TypeScript narrows to APIResponse
console.log(res.data);
}
}
Narrowing with satisfies
// satisfies validates the type without widening it
type Color = "red" | "green" | "blue";
type ColorMap = Record<Color, string | [number, number, number]>;
const palette = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255],
} satisfies ColorMap;
// palette.red is inferred as [number, number, number], not string | [number, number, number]
const redChannel = palette.red[0]; // OK - TypeScript knows it is a tuple
7. Branded / Opaque Types for Type Safety
Preventing Primitive Type Confusion
// Brand type utility
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
// Constructor functions with validation
function createUserId(id: string): UserId {
if (!id.match(/^usr_[a-zA-Z0-9]{12}$/)) {
throw new Error("Invalid user ID format");
}
return id as UserId;
}
function createEmail(email: string): Email {
if (!email.includes("@")) {
throw new Error("Invalid email format");
}
return email as Email;
}
function sendEmail(to: Email, subject: string): void {
// ...
}
function getUser(id: UserId): void {
// ...
}
const userId = createUserId("usr_abc123def456");
const email = createEmail("alice@example.com");
sendEmail(email, "Welcome!"); // OK
// sendEmail(userId, "Welcome!"); // Error: UserId is not assignable to Email
// getUser(email); // Error: Email is not assignable to UserId
Branded Numeric Types
type Celsius = Brand<number, "Celsius">;
type Fahrenheit = Brand<number, "Fahrenheit">;
type Kilometers = Brand<number, "Kilometers">;
type Miles = Brand<number, "Miles">;
function celsiusToFahrenheit(c: Celsius): Fahrenheit {
return ((c * 9) / 5 + 32) as Fahrenheit;
}
function milesToKilometers(m: Miles): Kilometers {
return (m * 1.60934) as Kilometers;
}
const temp = 100 as Celsius;
const converted = celsiusToFahrenheit(temp); // Fahrenheit
// celsiusToFahrenheit(100 as Fahrenheit); // Error: type mismatch
8. Builder Pattern with Types
Type-Safe Builder
interface QueryConfig {
table: string;
columns: string[];
where: string[];
orderBy: string | null;
limit: number | null;
}
type RequiredFields = "table" | "columns";
class QueryBuilder<T extends Partial<QueryConfig> = {}> {
private config: Partial<QueryConfig> = {};
from<Table extends string>(
table: Table
): QueryBuilder<T & { table: Table }> {
this.config.table = table;
return this as any;
}
select(
...columns: string[]
): QueryBuilder<T & { columns: string[] }> {
this.config.columns = columns;
return this as any;
}
where(condition: string): QueryBuilder<T & { where: string[] }> {
this.config.where = [...(this.config.where ?? []), condition];
return this as any;
}
orderBy(column: string): this {
this.config.orderBy = column;
return this;
}
limit(count: number): this {
this.config.limit = count;
return this;
}
// Only available when required fields are set
build(
this: QueryBuilder<Pick<QueryConfig, RequiredFields>>
): QueryConfig {
return {
table: this.config.table!,
columns: this.config.columns!,
where: this.config.where ?? [],
orderBy: this.config.orderBy ?? null,
limit: this.config.limit ?? null,
};
}
}
// Usage
const query = new QueryBuilder()
.from("users")
.select("id", "name", "email")
.where("active = true")
.orderBy("name")
.limit(10)
.build(); // Only compiles because from() and select() were called
9. Runtime Validation with Zod
Schema Definition and Inference
import { z } from "zod";
// Define schema
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(["admin", "user", "moderator"]),
tags: z.array(z.string()).default([]),
metadata: z.record(z.string(), z.unknown()).optional(),
createdAt: z.coerce.date(),
});
// Infer the TypeScript type from the schema
type User = z.infer<typeof UserSchema>;
// {
// id: string;
// name: string;
// email: string;
// age?: number;
// role: "admin" | "user" | "moderator";
// tags: string[];
// metadata?: Record<string, unknown>;
// createdAt: Date;
// }
// Validate at runtime
function createUser(input: unknown): User {
return UserSchema.parse(input); // throws ZodError if invalid
}
// Safe parsing (no throw)
function tryCreateUser(input: unknown): User | null {
const result = UserSchema.safeParse(input);
if (result.success) {
return result.data;
}
console.error("Validation errors:", result.error.flatten());
return null;
}
API Request/Response Validation
const CreateUserRequest = UserSchema.omit({ id: true, createdAt: true });
const UpdateUserRequest = CreateUserRequest.partial();
const APIResponse = <T extends z.ZodType>(dataSchema: T) =>
z.object({
success: z.boolean(),
data: dataSchema,
meta: z
.object({
page: z.number(),
total: z.number(),
})
.optional(),
});
const UserListResponse = APIResponse(z.array(UserSchema));
type UserListResponse = z.infer<typeof UserListResponse>;
10. Common TypeScript Errors and Fixes
TS2322: Type 'X' is not assignable to type 'Y'
// Problem
const status: "active" | "inactive" = "active";
let mutable = status;
// mutable is inferred as string, not the literal type
// Fix: use const assertion or explicit type
let mutable: typeof status = status;
// OR
const statuses = ["active", "inactive"] as const;
type Status = (typeof statuses)[number]; // "active" | "inactive"
TS2345: Argument of type 'X' is not assignable to parameter of type 'Y'
// Problem: passing an object literal with extra properties
interface Options {
timeout: number;
}
function configure(opts: Options) {}
// Fix: use a variable (excess property check only applies to literals)
const opts = { timeout: 5000, retries: 3 };
configure(opts); // OK - no excess property check on variables
TS7053: Element implicitly has an 'any' type (index access)
// Problem
const config: Record<string, unknown> = {};
const value = config["key"]; // unknown
// Fix: use a type guard or assertion
function getString(obj: Record<string, unknown>, key: string): string {
const val = obj[key];
if (typeof val !== "string") {
throw new Error(`Expected string at key "${key}"`);
}
return val;
}
TS2339: Property does not exist on type
// Problem with union types
type Result = { ok: true; value: string } | { ok: false; error: string };
function handle(result: Result) {
// result.value // Error: property doesn't exist on { ok: false; ... }
// Fix: narrow the type first
if (result.ok) {
console.log(result.value); // OK after narrowing
} else {
console.log(result.error);
}
}
11. Declaration Merging and Module Augmentation
Interface Merging
// Extend a third-party library's types
declare module "express" {
interface Request {
user?: {
id: string;
role: string;
};
requestId: string;
}
}
// Now express Request has your custom fields
import { Request } from "express";
function handler(req: Request) {
console.log(req.user?.id); // OK
console.log(req.requestId); // OK
}
Global Augmentation
// Add custom properties to globalThis
declare global {
interface Window {
__APP_CONFIG__: {
apiUrl: string;
environment: "development" | "staging" | "production";
};
}
// Add utility to Array prototype (declaration only)
interface Array<T> {
groupBy<K extends string>(fn: (item: T) => K): Record<K, T[]>;
}
}
export {}; // Required to make this a module
Namespace Merging
// Merge a namespace with a function (overloaded module pattern)
function currency(amount: number): string;
function currency(amount: number, code: string): string;
function currency(amount: number, code?: string): string {
return `${(code ?? "USD")} ${amount.toFixed(2)}`;
}
namespace currency {
export const codes = ["USD", "EUR", "GBP", "JPY"] as const;
export type Code = (typeof codes)[number];
export function convert(amount: number, from: Code, to: Code): number {
// conversion logic
return amount;
}
}
currency(42); // "USD 42.00"
currency.convert(100, "USD", "EUR"); // uses namespace
12. Strict Mode Best Practices
Recommended tsconfig.json Strict Settings
{
"compilerOptions": {
// Enable all strict checks
"strict": true,
// Individual flags (all enabled by "strict": true)
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
// Additional strictness beyond "strict"
"noUncheckedIndexedAccess": true, // arr[0] is T | undefined
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true,
// Module and target
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"lib": ["ES2022"],
// Output
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
Handling Strict Null Checks
// With strictNullChecks enabled
function getUser(id: string): User | undefined {
return users.get(id);
}
// Option 1: Early return guard
function getUserName(id: string): string {
const user = getUser(id);
if (!user) {
throw new Error(`User ${id} not found`);
}
return user.name; // user is narrowed to User
}
// Option 2: Non-null assertion (use sparingly, only when you are certain)
function unsafeGetName(id: string): string {
return getUser(id)!.name;
}
// Option 3: Optional chaining with nullish coalescing
function safeGetName(id: string): string {
return getUser(id)?.name ?? "Anonymous";
}
noUncheckedIndexedAccess Pattern
// With noUncheckedIndexedAccess, array/record access returns T | undefined
const items: string[] = ["a", "b", "c"];
const first = items[0]; // string | undefined
// Guard before use
if (first !== undefined) {
console.log(first.toUpperCase()); // OK
}
// Or use a helper
function at<T>(arr: T[], index: number): T {
const item = arr[index];
if (item === undefined) {
throw new RangeError(`Index ${index} out of bounds`);
}
return item;
}
Quick Reference: When to Use What
| Pattern | Use Case |
|---|---|
| Generics | Reusable functions/classes that work with multiple types |
| Utility types | Transform existing types (pick fields, make optional, etc.) |
| Conditional types | Types that depend on other types at compile time |
| Mapped types | Transform all properties of a type systematically |
| Template literals | Generate string literal union types |
| Discriminated unions | Model states, events, actions with tagged variants |
| Type guards | Runtime checks that narrow types for the compiler |
| Branded types | Prevent mixing up primitives (UserId vs OrderId) |
| Builder pattern | Enforce required steps at compile time |
| Zod schemas | Runtime validation with automatic type inference |
| Declaration merging | Extend third-party or global types |
| Strict mode | Catch more bugs at compile time |