Skip to content

3. Generics

Generics let a type or function work with many types while preserving the relationship between them. Interviewers care that you can write one, not just consume one.


1. The Problem Generics Solve

ts
// ❌ Loses the type
function firstAny(arr: any[]): any {
  return arr[0];
}
const x = firstAny([1, 2, 3]);   // any — no autocomplete, no safety

// ✅ Preserves it
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
const y = first([1, 2, 3]);      // number | undefined
const z = first(["a", "b"]);     // string | undefined

T is a type parameter — a placeholder filled in at the call site.

Interview Point

The one-liner: "Generics preserve the relationship between input and output types. any throws that relationship away."


2. Basic Syntax

ts
// Function
function identity<T>(value: T): T {
  return value;
}

// Arrow function
const identity = <T,>(value: T): T => value;   // trailing comma needed in .tsx

// Interface
interface Box<T> {
  value: T;
}

// Type alias
type Pair<A, B> = { first: A; second: B };

// Class
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.at(-1); }
}

const stack = new Stack<number>();

Inference vs Explicit

ts
identity("hello");        // T inferred as string
identity<string>("hi");   // explicit — only needed when inference fails

Let TypeScript infer wherever it can.


3. Constraints — extends

Restrict what a type parameter can be.

ts
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

longest("hello", "hi");        // OK — strings have length
longest([1, 2], [1, 2, 3]);    // OK — arrays have length
// longest(10, 20);            // Error — numbers don't

Without the constraint you cannot access .length at all, because T could be anything.

Multiple Type Parameters

ts
function merge<T extends object, U extends object>(a: T, b: U): T & U {
  return { ...a, ...b };
}

const result = merge({ name: "Dipak" }, { age: 25 });
// { name: string; age: number }

4. keyof and Indexed Access

The pair that makes most real-world generics work.

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

type UserKey = keyof User;          // "id" | "name" | "email"
type NameType = User["name"];       // string
type Values = User[keyof User];     // number | string

The Classic Type-Safe Getter

ts
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user: User = { id: 1, name: "Dipak", email: "d@x.com" };

getProperty(user, "name");   // string
getProperty(user, "id");     // number
// getProperty(user, "age"); // Error — "age" is not a key of User

Note the return type: T[K], not any. The exact property type flows through. This is the single most-asked "write a generic" question.

Type-Safe Setter and Pick

ts
function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
  obj[key] = value;
}

function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}

pluck(users, "email");   // string[]

5. Default Type Parameters

ts
interface ApiResponse<T = unknown> {
  data: T;
  status: number;
}

const a: ApiResponse = { data: "anything", status: 200 };
const b: ApiResponse<User[]> = { data: users, status: 200 };

6. Generic Constraints With Defaults

Real-world example — a typed fetch wrapper:

ts
interface FetchOptions extends RequestInit {
  timeout?: number;
}

async function apiClient<TResponse, TBody = unknown>(
  url: string,
  body?: TBody,
  options: FetchOptions = {}
): Promise<TResponse> {
  const res = await fetch(url, {
    ...options,
    method: body ? "POST" : "GET",
    headers: { "Content-Type": "application/json", ...options.headers },
    body: body ? JSON.stringify(body) : undefined,
  });

  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<TResponse>;
}

const user = await apiClient<User>("/api/users/1");
const created = await apiClient<User, CreateUserDto>("/api/users", dto);

Caveat worth stating: res.json() as Promise<TResponse> is an assertion, not a guarantee. In production, parse with Zod — see Narrowing.


7. Generic React Components

tsx
interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
  keyExtractor: (item: T) => string | number;
}

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

<List
  items={users}
  keyExtractor={(u) => u.id}
  renderItem={(u) => u.name}   // u is inferred as User
/>

Full inference from items — no explicit type argument needed at the call site.

The .tsx Arrow Function Gotcha

tsx
const f = <T>(x: T) => x;    // Error in .tsx — parsed as a JSX tag
const f = <T,>(x: T) => x;   // OK — trailing comma disambiguates
const f = <T extends unknown>(x: T) => x;   // also works

8. Generic Custom Hooks

ts
function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setStoredValue = useCallback(
    (v: T | ((prev: T) => T)) => {
      setValue((prev) => {
        const next = v instanceof Function ? v(prev) : v;
        window.localStorage.setItem(key, JSON.stringify(next));
        return next;
      });
    },
    [key]
  );

  return [value, setStoredValue] as const;   // as const keeps it a tuple
}

const [theme, setTheme] = useLocalStorage("theme", "dark");
// theme: string, setTheme accepts string or (prev: string) => string

as const is the key detail. Without it the return type widens to (string | ((v) => void))[] and destructuring loses the types.


9. Conditional Types

The gateway to advanced TypeScript. Know the syntax; you rarely need to write complex ones.

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

type A = IsString<"hello">;   // true
type B = IsString<42>;        // false

infer — Extract a Type

ts
type ElementType<T> = T extends (infer U)[] ? U : never;
type A = ElementType<string[]>;   // string

type Unwrap<T> = T extends Promise<infer U> ? U : T;
type B = Unwrap<Promise<User>>;   // User

This is exactly how the built-in ReturnType<T> works:

ts
type ReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : any;

Distributive Conditional Types

Conditional types distribute over unions automatically:

ts
type NonNullableT<T> = T extends null | undefined ? never : T;
type A = NonNullableT<string | null>;   // string

Wrap in a tuple to prevent distribution: [T] extends [U] ? ….

Interview Point

Understanding conditional types matters more for reading library code than for writing app code. If asked, show you can read ReturnType and explain infer — that is enough for an application-developer role.


10. Mapped Types

Transform every property of a type.

ts
type Optional<T> = { [K in keyof T]?: T[K] };
type Nullable<T> = { [K in keyof T]: T[K] | null };
type Immutable<T> = { readonly [K in keyof T]: T[K] };

Modifiers — Add and Remove

ts
type Mutable<T> = { -readonly [K in keyof T]: T[K] };   // remove readonly
type Required<T> = { [K in keyof T]-?: T[K] };          // remove optional

The - prefix strips a modifier; a bare modifier adds it.

Key Remapping with as

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

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

Combines mapped types with template literal types. Nice to recognise; rarely needed in app code.

A Genuinely Useful One

ts
// Make some keys optional, keep the rest required
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

type NewUser = PartialBy<User, "id" | "createdAt">;
// id and createdAt optional, everything else required

This one appears in real codebases constantly — the "create" DTO for an entity.


11. When NOT To Use Generics

A generic used once, with one type parameter appearing in only one position, is just an annotation with extra steps.

ts
// ❌ Pointless — T appears only in the parameter
function log<T>(value: T): void {
  console.log(value);
}
// Same as: function log(value: unknown): void

// ✅ Meaningful — T links input to output
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

The Rule

A type parameter must appear in at least two places to be doing work — linking a parameter to the return type, or two parameters to each other. Otherwise use unknown.

Saying this unprompted signals you have moved past "generics are cool" into "generics have a purpose".

© 2025 DDocs · Dipak's Documentation Guide