References

Advanced Generics

Constraints, conditional types, mapped types, and advanced generic patterns

Core Patterns

  • When to Read This
  • Generic Constraints
  • Generic Factory Patterns
  • Conditional Types

When to Read This

  • Creating reusable generic functions/components
  • Working with conditional types
  • Building mapped types
  • Using infer keyword
  • Implementing factory patterns or recursive types
  • Working with variadic tuple types
  • Building generic React components and hooks

Generic Constraints

Extends Constraint

// ❌ WRONG: Unconstrained generic
function getProperty<T>(obj: T, key: string) {
  return obj[key]; // Error: key not known to exist
}

// ✅ CORRECT: Constrained to objects with keys
function getProperty<T extends object, K extends keyof T>(
  obj: T,
  key: K,
): T[K] {
  return obj[key]; // Type-safe
}

Multiple Generic Constraints

// ✅ CORRECT: Intersection constraint — T must satisfy both interfaces
interface HasId {
  id: string;
}
interface HasTimestamp {
  createdAt: Date;
}

function logEntity<T extends HasId & HasTimestamp>(entity: T): void {
  console.log(entity.id, entity.createdAt);
}

// ❌ WRONG: Trying to use multiple extends clauses
// function logEntity<T extends HasId extends HasTimestamp>(entity: T) {}

// ✅ CORRECT: Conditional constraint via overloads
function process<T extends string>(val: T): string;
function process<T extends number>(val: T): number;
function process(val: string | number) {
  return val;
}

Generic Factory Patterns

// ✅ CORRECT: Typed factory function with constructor constraint
function create<T>(Ctor: new () => T): T {
  return new Ctor();
}

class Dog {
  bark() { return "woof"; }
}
const dog = create(Dog); // type: Dog

// ✅ CORRECT: Factory with constructor arguments
function createWith<T, A extends unknown[]>(
  Ctor: new (...args: A) => T,
  ...args: A
): T {
  return new Ctor(...args);
}

class User {
  constructor(public name: string, public age: number) {}
}
const user = createWith(User, "Alice", 30); // type: User

// ❌ WRONG: Losing type information with Function
function createBad(Ctor: Function): unknown {
  return new (Ctor as any)();
}

// ✅ CORRECT: Registry factory with mapped types
interface Registry {
  user: User;
  dog: Dog;
}

function createFromRegistry<K extends keyof Registry>(
  key: K,
): Registry[K] {
  const map: { [P in keyof Registry]: new () => Registry[P] } = {
    user: User as any,
    dog: Dog,
  };
  return new map[key]();
}

const u = createFromRegistry("user"); // type: User

Conditional Types

type IsString<T> = T extends string ? true : false;

type A = IsString<string>; // true
type B = IsString<number>; // false

// Extract array element type
type ArrayElement<T> = T extends (infer U)[] ? U : never;

type Nums = ArrayElement<number[]>; // number
type Str = ArrayElement<string>; // never

Distributive Conditional Types

// Conditional types DISTRIBUTE over unions by default.
// Each union member is evaluated independently.

type ToArray<T> = T extends unknown ? T[] : never;

type Result = ToArray<string | number>;
// string[] | number[]   (NOT (string | number)[])

// ✅ CORRECT: Prevent distribution by wrapping in tuple
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;

type Result2 = ToArrayNonDist<string | number>;
// (string | number)[]   — no distribution

// Practical example: exclude null/undefined
type NonNullable<T> = T extends null | undefined ? never : T;

type Clean = NonNullable<string | null | undefined>;
// string — distributes, null and undefined become never

// ❌ WRONG: Expecting non-distributed behavior from bare conditional
type IsNever<T> = T extends never ? true : false;
type Oops = IsNever<never>; // never (not true! — distributes over empty union)

// ✅ CORRECT: Wrap to detect never
type IsNeverFixed<T> = [T] extends [never] ? true : false;
type Fixed = IsNeverFixed<never>; // true

Infer Keyword

// Extract return type
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

// Extract Promise type
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type Num = UnwrapPromise<Promise<number>>; // number
type Str = UnwrapPromise<string>; // string

Recursive Types

// ✅ CORRECT: Typed JSON value
type JsonValue =
  | string
  | number
  | boolean
  | null
  | JsonValue[]
  | { [key: string]: JsonValue };

const config: JsonValue = {
  name: "app",
  port: 3000,
  features: ["auth", "logging"],
  db: { host: "localhost", ssl: true },
};

// ✅ CORRECT: DeepPartial — makes every nested property optional
type DeepPartial<T> = T extends object
  ? { [P in keyof T]?: DeepPartial<T[P]> }
  : T;

interface Config {
  server: { host: string; port: number };
  db: { url: string; pool: { min: number; max: number } };
}

// All nested fields are optional
const partial: DeepPartial<Config> = {
  db: { pool: { max: 20 } },
};

// ✅ CORRECT: DeepReadonly — makes every nested property readonly
type DeepReadonly<T> = T extends primitive
  ? T
  : T extends Array<infer U>
    ? ReadonlyArray<DeepReadonly<U>>
    : { readonly [P in keyof T]: DeepReadonly<T[P]> };

type primitive = string | number | boolean | null | undefined;

// ❌ WRONG: Shallow Readonly misses nested mutation
interface State {
  user: { name: string; scores: number[] };
}

type ShallowRO = Readonly<State>;
// state.user is readonly but state.user.name is still mutable!

// ✅ CORRECT: DeepReadonly catches nested mutation
type DeepRO = DeepReadonly<State>;
// state.user.name and state.user.scores are all readonly

// ✅ CORRECT: Deep key paths (recursive template literals)
type DeepKeys<T, Prefix extends string = ""> = T extends object
  ? {
      [K in keyof T & string]: K | `${K}.${DeepKeys<T[K]>}`;
    }[keyof T & string]
  : never;

type ConfigKeys = DeepKeys<Config>;
// "server" | "server.host" | "server.port" | "db" | "db.url" | "db.pool" | ...

Mapped Types

type Optional<T> = {
  [P in keyof T]?: T[P];
};

type Nullable<T> = {
  [P in keyof T]: T[P] | null;
};

type Getters<T> = {
  [P in keyof T as `get${Capitalize<string & P>}`]: () => T[P];
};

interface User {
  name: string;
  age: number;
}

type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number; }

Template Literal Types

type HttpMethod = "GET" | "POST";
type Route = "/users" | "/posts";

type Endpoint = `${HttpMethod} ${Route}`;
// 'GET /users' | 'GET /posts' | 'POST /users' | 'POST /posts'

Variadic Tuple Types

// ✅ CORRECT: Typed concat
function concat<T extends unknown[], U extends unknown[]>(
  a: [...T],
  b: [...U],
): [...T, ...U] {
  return [...a, ...b];
}

const result = concat([1, "hello"] as const, [true, 42] as const);
// type: [1, "hello", true, 42]

// ✅ CORRECT: Typed head and tail
type Head<T extends unknown[]> = T extends [infer H, ...unknown[]] ? H : never;
type Tail<T extends unknown[]> = T extends [unknown, ...infer R] ? R : never;

type H = Head<[string, number, boolean]>; // string
type T = Tail<[string, number, boolean]>; // [number, boolean]

// ✅ CORRECT: Curry with variadic tuples
type Curry<Args extends unknown[], Ret> = Args extends [
  infer First,
  ...infer Rest,
]
  ? (arg: First) => Curry<Rest, Ret>
  : Ret;

declare function curry<A extends unknown[], R>(
  fn: (...args: A) => R,
): Curry<A, R>;

function add(a: number, b: number, c: number) {
  return a + b + c;
}

const curried = curry(add);
// type: (arg: number) => (arg: number) => (arg: number) => number
const six = curried(1)(2)(3); // 6

// ❌ WRONG: Losing tuple structure with spread
function bad<T extends unknown[]>(...args: T): unknown[] {
  return args; // Return type is unknown[], not T
}

// ✅ CORRECT: Preserving tuple structure
function good<T extends unknown[]>(...args: T): T {
  return args;
}

const preserved = good(1, "a", true); // type: [number, string, boolean]

// ✅ CORRECT: Rest elements in tuple types
type StrNumBools = [string, number, ...boolean[]];
type HasHead = [first: string, ...rest: number[]];

Generic Component Patterns (React)

// ✅ CORRECT: Generic list component
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string;
}

function List<T>(props: ListProps<T>) {
  const { items, renderItem, keyExtractor } = props;
  return (
    <ul>
      {items.map((item, i) => (
        <li key={keyExtractor(item)}>{renderItem(item, i)}</li>
      ))}
    </ul>
  );
}

// Usage — T is inferred as { id: string; name: string }
<List
  items={[{ id: "1", name: "Alice" }]}
  renderItem={(item) => <span>{item.name}</span>}
  keyExtractor={(item) => item.id}
/>;

// ✅ CORRECT: Generic hook — useList<T>
function useList<T>(initial: T[] = []) {
  const [items, setItems] = React.useState<T[]>(initial);

  const add = React.useCallback((item: T) => {
    setItems((prev) => [...prev, item]);
  }, []);

  const remove = React.useCallback((predicate: (item: T) => boolean) => {
    setItems((prev) => prev.filter((item) => !predicate(item)));
  }, []);

  return { items, add, remove } as const;
}

// Usage — T is inferred as { id: number; label: string }
const { items, add, remove } = useList([{ id: 1, label: "Todo" }]);
add({ id: 2, label: "New" }); // OK
// add({ wrong: true });       // Error

// ✅ CORRECT: Generic component with forwardRef
interface InputProps<T extends string | number> {
  value: T;
  onChange: (value: T) => void;
}

// Use a wrapper pattern since forwardRef does not preserve generics
function GenericInput<T extends string | number>(
  props: InputProps<T> & { ref?: React.Ref<HTMLInputElement> },
) {
  return <input value={props.value} onChange={(e) => props.onChange(e.target.value as T)} ref={props.ref} />;
}

// ❌ WRONG: Inline generic lost in JSX
// <List<User> items={users} /> — only works with tsx when the parser can distinguish from JSX
// function identity<T>(x: T) {} — ambiguous in .tsx files

// ✅ CORRECT: Add trailing comma in .tsx to disambiguate
// const identity = <T,>(x: T): T => x;

Common Pitfalls

Overusing Generics

// ❌ WRONG: Generic adds no value — T is always string
function greet<T extends string>(name: T): string {
  return `Hello, ${name}`;
}

// ✅ CORRECT: Simple parameter type suffices
function greet(name: string): string {
  return `Hello, ${name}`;
}

// ❌ WRONG: Generic used once and not relating inputs to outputs
function log<T>(value: T): void {
  console.log(value);
}

// ✅ CORRECT: unknown is sufficient when T is not reused
function log(value: unknown): void {
  console.log(value);
}

// ✅ WHEN TO USE: Generic relates input to output
function identity<T>(value: T): T {
  return value;
}

Generic Inference Failures

// ❌ PROBLEM: TypeScript cannot infer T from both arguments independently
function merge<T>(defaults: T, overrides: T): T {
  return { ...defaults, ...overrides };
}

// This fails: { a: 1, b: 2 } and { b: 3, c: 4 } are different shapes
// merge({ a: 1, b: 2 }, { b: 3, c: 4 }); // Error

// ✅ FIX 1: Provide explicit type argument
merge<{ a?: number; b: number; c?: number }>(
  { a: 1, b: 2 },
  { b: 3, c: 4 },
);

// ✅ FIX 2: Use two type parameters
function merge2<T, U>(defaults: T, overrides: U): T & U {
  return { ...defaults, ...overrides };
}

// ❌ PROBLEM: Inference fails with callbacks in certain positions
declare function fetchData<T>(url: string, parser: (raw: unknown) => T): T;

// T inferred as unknown — parser doesn't help narrow
const data = fetchData("/api", (raw) => raw as string);

// ✅ FIX: Explicit type argument when inference is ambiguous
const data2 = fetchData<string>("/api", (raw) => raw as string);

extends vs = in Generic Defaults

// `extends` sets a CONSTRAINT — T must be assignable to this type
// `=` sets a DEFAULT — used when T is not provided

// ❌ WRONG: Confusing extends (constraint) with = (default)
interface Box<T extends string> {
  value: T;
}
// Box<number> — Error: number does not extend string
// Box — Error: T has no default

// ✅ CORRECT: Constraint with a default
interface Box<T extends string = string> {
  value: T;
}
const b1: Box = { value: "hello" };       // T defaults to string
const b2: Box<"hi"> = { value: "hi" };    // T is literal "hi"
// Box<number> — still Error: number does not extend string

// ✅ CORRECT: Default without constraint
interface Container<T = unknown> {
  value: T;
}
const c1: Container = { value: 42 };           // T defaults to unknown
const c2: Container<string> = { value: "ok" }; // T is string

// ❌ WRONG: Default that violates its own constraint
// interface Bad<T extends string = number> {} // Error: number not assignable to string

References