Back to Blog
Frontend

Advanced TypeScript Patterns: Generics, Utility Types, and Type-Safe APIs

February 4, 202614 min read

Go beyond basic TypeScript. Master conditional types, mapped types, template literals, inferred generics, and builder patterns to write truly type-safe APIs and libraries.

Advanced TypeScript Patterns: Generics, Utility Types, and Type-Safe APIs

Introduction

TypeScript's type system is Turing-complete. You can express remarkably complex constraints — ensuring API return types match their inputs, building builder patterns with accumulated state, and creating DSLs that catch errors at compile time. This guide covers the patterns that separate good TypeScript from great TypeScript.


1. Generic Constraints and Inference

// Basic generic — accepts anything
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

// Constrained generic — must have an id property
function findById<T extends { id: string }>(arr: T[], id: string): T | undefined {
  return arr.find(item => item.id === id);
}

// Inferred return type based on input
function mapKeys<T extends Record<string, unknown>, K extends keyof T>(
  obj: T,
  keys: K[]
): Pick<T, K> {
  return Object.fromEntries(
    keys.map(k => [k, obj[k]])
  ) as Pick<T, K>;
}

const user = { id: '1', name: 'Alice', role: 'admin', createdAt: new Date() };
const picked = mapKeys(user, ['id', 'name']);
// Type: { id: string; name: string } — TypeScript knows exactly what you got

2. Conditional Types

// IsArray<T> — resolves to the element type if T is an array
type Unwrap<T> = T extends (infer U)[] ? U : T;
type A = Unwrap<string[]>;  // string
type B = Unwrap<number>;    // number

// DeepPartial — recursively make all properties optional
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

// NonNullableDeep — strip null/undefined recursively
type DeepNonNullable<T> = {
  [K in keyof T]-?: T[K] extends null | undefined
    ? never
    : T[K] extends object
    ? DeepNonNullable<T[K]>
    : T[K];
};

3. Template Literal Types

type EventName = 'click' | 'focus' | 'blur';
type Handler = `on${Capitalize<EventName>}`;
// 'onClick' | 'onFocus' | 'onBlur'

// Type-safe event emitter
type EventMap = {
  userCreated: { userId: string; email: string };
  orderPlaced: { orderId: string; amount: number };
  paymentFailed: { orderId: string; reason: string };
};

type EventNames = keyof EventMap;

class TypedEventEmitter {
  on<E extends EventNames>(
    event: E,
    handler: (payload: EventMap[E]) => void
  ): this {
    // implementation
    return this;
  }

  emit<E extends EventNames>(event: E, payload: EventMap[E]): void {
    // implementation
  }
}

const emitter = new TypedEventEmitter();
emitter.on('userCreated', ({ userId, email }) => {
  // userId and email are fully typed
});
// emitter.emit('userCreated', { orderId: '...' }) — ❌ TypeScript error

4. Builder Pattern with Accumulating Types

// A query builder where the type accurately reflects what's been configured
class QueryBuilder<T extends Record<string, unknown> = Record<string, never>> {
  private query: Partial<T> = {};

  where<K extends string, V>(
    key: K,
    value: V
  ): QueryBuilder<T & Record<K, V>> {
    (this.query as Record<string, unknown>)[key] = value;
    return this as unknown as QueryBuilder<T & Record<K, V>>;
  }

  build(): T {
    return this.query as T;
  }
}

const query = new QueryBuilder()
  .where('userId', '123')      // QueryBuilder<{ userId: string }>
  .where('status', 'active')   // QueryBuilder<{ userId: string; status: string }>
  .build();

// query.userId — ✅ string
// query.status — ✅ string
// query.notHere — ❌ TypeScript error

5. Discriminated Unions for State Machines

type AsyncState<T, E = string> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: E };

function renderUserProfile(state: AsyncState<User>) {
  switch (state.status) {
    case 'idle':    return <p>Not started</p>;
    case 'loading': return <Spinner />;
    case 'success': return <UserCard user={state.data} />; // state.data is User ✅
    case 'error':   return <p>Error: {state.error}</p>;    // state.error is string ✅
  }
}

6. Type-Safe API Client with Inferred Types

// Define your API schema
const apiSchema = {
  '/users/:id': {
    GET: { response: {} as User },
  },
  '/orders': {
    GET: { response: {} as Order[] },
    POST: { body: {} as CreateOrderInput, response: {} as Order },
  },
} as const;

type Schema = typeof apiSchema;
type Routes = keyof Schema;
type Methods<R extends Routes> = keyof Schema[R];

// The client infers the return type from the route and method
async function apiClient<
  R extends Routes,
  M extends Methods<R>
>(route: R, method: M): Promise<Schema[R][M] extends { response: infer Res } ? Res : never> {
  const response = await fetch(route as string, { method: method as string });
  return response.json();
}

const user = await apiClient('/users/:id', 'GET');
// user is typed as User — no casting needed

Conclusion

Advanced TypeScript isn't about complexity for its own sake — it's about moving runtime errors to compile time. Conditional types, template literals, and discriminated unions let you encode your business logic in the type system itself.

Key takeaways:

  • Constrained generics preserve type information through transformations
  • Discriminated unions are the right model for state machines and async state
  • Builder patterns with accumulating types give you a typed DSL for free
  • Template literal types let you derive event names, CSS classes, and API routes from a single source of truth

Tags

TypeScriptAdvanced TypeScriptGenericsType SafetyDesign Patterns