Chapter 6

Generics in Depth: Constraints, Defaults, Type-Safe fetch

<T> Is a Relationship, Not a Placeholder

The common beginner mental model — "T is just a blank you can fill with any type" — works for trivial cases but breaks down quickly. The accurate mental model: <T> describes the relationship between what the caller provides and how the function behaves internally.

// Wrong mental model: "this function accepts any value because T is anything"
function identity<T>(value: T): T {
  return value;
}

// Right mental model: T is inferred at the call site to a concrete type,
// and the return type is locked to that same concrete type.
const s = identity("hello"); // T = string, returns string
const n = identity(42);      // T = number, returns number

// This is what generics are for: constraining input-output relationships
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
const x = first([1, 2, 3]);  // T = number, x: number | undefined
const y = first(["a", "b"]); // T = string, y: string | undefined

extends Constraints

An unconstrained <T> knows nothing about T. The compiler won't let you access any property or call any method on it. The extends constraint tells the compiler what T is guaranteed to have.

// Anti-pattern: unconstrained T, compiler blocks property access
function getLength<T>(value: T): number {
  return value.length; // Error: Property 'length' does not exist on type 'T'
}

// Correct: constrain T to have a length property
function getLength<T extends { length: number }>(value: T): number {
  return value.length;
}

getLength("hello");    // OK — strings have length
getLength([1, 2, 3]);  // OK — arrays have length
getLength(42);         // Error: number does not satisfy the constraint

<T extends keyof U> — Property Name Constraints

This is one of the most used generic constraint patterns in TypeScript:

// Unsafe version — loses all type information
function getProp(obj: object, key: string): unknown {
  return (obj as any)[key];
}

// Type-safe version — return type is precisely T[K]
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Alice", age: 30, active: true };
const name = getProp(user, "name");   // type: string
const age  = getProp(user, "age");    // type: number
getProp(user, "email"); // Error: 'email' does not exist in type of user

Object Constraints

// T extends object prevents primitives from being passed
function merge<T extends object, U extends object>(a: T, b: U): T & U {
  return { ...a, ...b };
}

const result = merge({ x: 1 }, { y: "hello" });
// result: { x: number; y: string }

merge(1, { y: 2 }); // Error: number does not satisfy 'extends object'

Multiple Type Parameters

Multiple type parameters can reference each other to encode richer relationships:

// K, V independent: straightforward key-value mapping
function mapKeys<K extends string, V>(
  keys: K[],
  getValue: (key: K) => V
): Record<K, V> {
  return Object.fromEntries(keys.map(k => [k, getValue(k)])) as Record<K, V>;
}

const scores = mapKeys(["alice", "bob"], name => name.length);
// type: Record<"alice" | "bob", number>

// U extends keyof T: the second parameter must be a subset of T's keys
function pick<T, U extends keyof T>(obj: T, keys: U[]): Pick<T, U> {
  const result = {} as Pick<T, U>;
  keys.forEach(k => { result[k] = obj[k]; });
  return result;
}

const user = { id: 1, name: "Alice", email: "[email protected]", role: "admin" };
const safe = pick(user, ["id", "name"]); // { id: number; name: string }

Generic Defaults: <T = string>

When the caller doesn't supply a type argument, the default kicks in:

// Without default: T inferred as unknown when not specified
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

// With default: T = string when omitted
interface ApiResponse<T = string> {
  data: T;
  status: number;
  message: string;
}

// Uses default
const r1: ApiResponse = { data: "OK", status: 200, message: "" };
// Explicit override
const r2: ApiResponse<{ id: number }> = { data: { id: 1 }, status: 200, message: "" };

// Generic defaults on functions
function createList<T = string>(): T[] {
  return [];
}

const strings = createList();         // T = string, type: string[]
const numbers = createList<number>(); // explicit, type: number[]

Generic Interfaces and Classes

Generic Interfaces

// A generic repository pattern
interface Repository<T> {
  findById(id: number): Promise<T | null>;
  findAll(): Promise<T[]>;
  save(entity: T): Promise<T>;
  delete(id: number): Promise<void>;
}

interface User {
  id: number;
  name: string;
  email: string;
}

// Bind the concrete type at implementation time
class UserRepository implements Repository<User> {
  private users: User[] = [];

  async findById(id: number): Promise<User | null> {
    return this.users.find(u => u.id === id) ?? null;
  }

  async findAll(): Promise<User[]> {
    return [...this.users];
  }

  async save(user: User): Promise<User> {
    const idx = this.users.findIndex(u => u.id === user.id);
    if (idx >= 0) this.users[idx] = user;
    else this.users.push(user);
    return user;
  }

  async delete(id: number): Promise<void> {
    this.users = this.users.filter(u => u.id !== id);
  }
}

Generic Classes

// A type-safe LIFO stack
class Stack<T> {
  private items: T[] = [];

  push(item: T): void {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }

  get size(): number {
    return this.items.length;
  }

  isEmpty(): boolean {
    return this.items.length === 0;
  }
}

const numStack = new Stack<number>();
numStack.push(1);
numStack.push(2);
const top = numStack.pop(); // type: number | undefined

const strStack = new Stack<string>();
strStack.push("hello");
strStack.push(42); // Error: Argument of type 'number' is not assignable to 'string'

keyof and typeof Combined with Generics

// keyof extracts union of a type's keys
type UserKeys = keyof User; // "id" | "name" | "email"

// typeof infers a type from a value
const defaultConfig = {
  timeout: 5000,
  retries: 3,
  baseUrl: "https://api.example.com",
};

type Config = typeof defaultConfig;
// { timeout: number; retries: number; baseUrl: string }

// Combine with generics: runtime-safe property access with fallback
function getRequired<T, K extends keyof T>(
  obj: T,
  key: K,
  fallback: NonNullable<T[K]>
): NonNullable<T[K]> {
  const val = obj[key];
  return (val != null ? val : fallback) as NonNullable<T[K]>;
}

interface Config {
  timeout?: number;
  host?: string;
}

const cfg: Config = { timeout: 3000 };
const timeout = getRequired(cfg, "timeout", 5000); // number, not undefined
const host    = getRequired(cfg, "host", "localhost"); // string, not undefined

Main Project: Building a Type-Safe fetch Wrapper

Step 1: The Minimal Version

async function api<T>(url: string, options?: RequestInit): Promise<T> {
  const res = await fetch(url, options);
  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${res.statusText}`);
  }
  return res.json() as Promise<T>;
}

// Usage: caller declares the expected shape
interface Post {
  id: number;
  title: string;
  body: string;
}

const post = await api<Post>("/api/posts/1");
post.title; // type: string, full IntelliSense available

Step 2: Structured Error Handling

class ApiError extends Error {
  constructor(
    public status: number,
    public statusText: string,
    public body: unknown
  ) {
    super(`HTTP ${status}: ${statusText}`);
    this.name = "ApiError";
  }
}

async function api<T>(url: string, options?: RequestInit): Promise<T> {
  const res = await fetch(url, options);
  if (!res.ok) {
    let body: unknown;
    try { body = await res.json(); } catch { body = null; }
    throw new ApiError(res.status, res.statusText, body);
  }
  return res.json() as Promise<T>;
}

Step 3: Base URL, Default Headers, and Timeout

interface ApiClientOptions {
  baseUrl: string;
  headers?: Record<string, string>;
  timeout?: number;
}

function createApiClient(clientOptions: ApiClientOptions) {
  const { baseUrl, headers: defaultHeaders = {}, timeout = 10000 } = clientOptions;

  async function request<T>(
    path: string,
    options: RequestInit & { params?: Record<string, string> } = {}
  ): Promise<T> {
    const { params, headers: reqHeaders, ...fetchOptions } = options;

    const url = new URL(path, baseUrl);
    if (params) {
      Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
    }

    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeout);

    try {
      const res = await fetch(url.toString(), {
        ...fetchOptions,
        headers: { ...defaultHeaders, ...reqHeaders as Record<string, string> },
        signal: controller.signal,
      });

      if (!res.ok) {
        let body: unknown;
        try { body = await res.json(); } catch { body = null; }
        throw new ApiError(res.status, res.statusText, body);
      }

      return res.json() as Promise<T>;
    } finally {
      clearTimeout(timer);
    }
  }

  return {
    get<T>(path: string, params?: Record<string, string>): Promise<T> {
      return request<T>(path, { method: "GET", params });
    },
    post<T>(path: string, body: unknown): Promise<T> {
      return request<T>(path, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
    },
    put<T>(path: string, body: unknown): Promise<T> {
      return request<T>(path, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
    },
    delete<T>(path: string): Promise<T> {
      return request<T>(path, { method: "DELETE" });
    },
  };
}

// Usage
const client = createApiClient({
  baseUrl: "https://api.example.com",
  headers: { Authorization: "Bearer token123" },
});

interface User { id: number; name: string; email: string; }
interface Post { id: number; title: string; userId: number; }

const user = await client.get<User>("/users/1");
const posts = await client.get<Post[]>("/posts", { userId: "1" });
const newUser = await client.post<User>("/users", { name: "Bob", email: "[email protected]" });

Generic Constraints for DOM Manipulation

// T extends HTMLElement: rejects plain objects, accepts only DOM elements
function querySelector<T extends HTMLElement>(
  selector: string,
  container: Document | Element = document
): T {
  const el = container.querySelector<T>(selector);
  if (!el) throw new Error(`Element not found: ${selector}`);
  return el;
}

const input = querySelector<HTMLInputElement>("#email");
input.value; // type: string — HTMLInputElement-specific

const canvas = querySelector<HTMLCanvasElement>("#chart");
canvas.getContext("2d"); // type: CanvasRenderingContext2D | null

// Generics + event listener with full event type inference
function addListener<K extends keyof HTMLElementEventMap>(
  el: HTMLElement,
  type: K,
  handler: (ev: HTMLElementEventMap[K]) => void
): () => void {
  el.addEventListener(type, handler as EventListener);
  return () => el.removeEventListener(type, handler as EventListener);
}

const btn = querySelector<HTMLButtonElement>("#submit");
const removeListener = addListener(btn, "click", (ev) => {
  ev.preventDefault(); // ev: MouseEvent — fully typed
});

Anti-Pattern: Over-Genericifying

// Anti-pattern: using a generic where a union type is clearer
function format<T extends "json" | "xml" | "csv">(data: unknown, type: T): string {
  // T provides no additional constraint power here
  if (type === "json") return JSON.stringify(data);
  if (type === "xml") return `<data>${data}</data>`;
  return String(data);
}

// Correct: a union type is simpler and equally precise
type FormatType = "json" | "xml" | "csv";
function format(data: unknown, type: FormatType): string {
  if (type === "json") return JSON.stringify(data);
  if (type === "xml") return `<data>${data}</data>`;
  return String(data);
}

// Anti-pattern: unrelated parameters stuffed into type parameters
function processAll<A, B, C>(a: A, b: B, c: C): [A, B, C] {
  return [a, b, c]; // A, B, C share no relationship — meaningless generics
}

// Correct: only use generics when there IS a type relationship
function pair<T, U>(first: T, second: U): [T, U] {
  return [first, second]; // callers get precisely typed tuples back
}

The test for whether you need generics:


Summary

Feature Syntax Typical Use
Basic generic <T> Identity functions, container types
Object constraint <T extends object> merge, deep clone
Property constraint <K extends keyof T> Safe property access
Multi-param constraint <T, U extends T> Subset relationships
Generic default <T = string> API response interfaces
keyof + generic <T, K extends keyof T>(obj: T, key: K): T[K] Type-safe property reads
DOM generic <T extends HTMLElement> querySelector wrappers

Next chapter: dissecting the source code of TypeScript's built-in utility types — how Partial, Pick, Omit, and Record are actually implemented.

Rate this chapter
4.7  / 5  (56 ratings)

💬 Comments