Generic Functions

8 min read

You've seen what a single type parameter does — function getFirst<T>(items: T[]): T keeps the input and output types linked. This lesson scales up: multiple type parameters, constraints (extends) that say "T must look like this," and keyof, the operator that powers type-safe property lookups. By the end you'll be able to write generics like the ones in real test frameworks.

A small wrapper, fully generic

Start simple. A wrapper that logs a value with a label and returns it untouched:

function logAndReturn<T>(label: string, value: T): T {
  console.log(`${label}:`, value);
  return value;
}
 
const user    = logAndReturn("Test user", { name: "Alice", role: "admin" });
const code    = logAndReturn("Status", 200);
const passed  = logAndReturn("Result", true);
// user is { name: string; role: string }
// code is number, passed is boolean

The generic <T> flows from the value parameter to the return type — whatever you pass in is what you get back, with every property of its type intact. This is the most common shape of a generic helper: one type parameter, threading through input and output.

Multiple type parameters

When a function relates two (or more) shapes that aren't the same, declare a separate parameter for each:

function createPair<TKey, TValue>(key: TKey, value: TValue): [TKey, TValue] {
  return [key, value];
}
 
const a = createPair("browser", "chromium");   // [string, string]
const b = createPair("timeout", 5000);          // [string, number]
const c = createPair(42, ["a", "b"]);           // [number, string[]]

Every type parameter is independent. TKey and TValue can be the same type, different types, or completely unrelated — TypeScript tracks them per call.

A more practical example — a map-like helper where input and output element types differ:

function mapItems<TInput, TOutput>(
  items: TInput[],
  fn: (item: TInput) => TOutput
): TOutput[] {
  return items.map(fn);
}
 
const names = mapItems(users, (u) => u.name);   // string[]
const ages  = mapItems(users, (u) => u.age);    // number[]

The callback's parameter type is inferred as TInput — TypeScript reads the array's element type and threads it into the callback. The callback's return type becomes TOutput. Every step typed, no annotations needed inside the lambda.

Constraints — making T behave

Sometimes a generic should only accept types that have certain properties. Without a constraint, T could be anything — including types that don't have the field your function needs.

interface HasId {
  id: string | number;
}
 
function findById<T extends HasId>(items: T[], id: string | number): T | undefined {
  return items.find((item) => item.id === id);
}
 
interface User    extends HasId { name: string }
interface Product extends HasId { sku: string }
 
findById(users, 1);           // ✅ User has id
findById(products, "sku-42"); // ✅ Product has id
findById(["a", "b"], 1);
// ❌ Type 'string' is not assignable to type 'HasId'.

The extends HasId clause says "T must be assignable to HasId" — meaning, T must have at least an id field. Inside the function body, you can safely access item.id because the constraint guarantees it.

Constraints are how you write parametric helpers that still know something about their inputs. Common shapes you'll constrain on:

  • An identifier: T extends { id: string | number } — for "find by id" helpers.
  • A property bag: T extends Record<string, unknown> — for "merge two objects."
  • A specific key: T extends { length: number } — for "longest item" helpers.

The trick is to constrain only what you actually need. A constraint of T extends User is too narrow — it locks the helper to one shape. T extends HasId is the right floor.

keyof — type-safe property access

The keyof operator returns the keys of a type as a union of string literals. Combined with a generic, it lets you write a property-getter that's checked at compile time:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
 
const user: User = { id: 1, name: "Alice", email: "a@test.com" };
 
const name  = getProperty(user, "name");   // ✅ type: string
const id    = getProperty(user, "id");     // ✅ type: number
const wrong = getProperty(user, "age");
// ❌ Argument of type '"age"' is not assignable to parameter of type 'keyof User'.

Read the signature slowly: K extends keyof T constrains K to one of the keys of T. T[K] is an indexed access type — "the type of T's property named K." Pass "name" and the return type is string; pass "id" and the return type is number. Typos at the call site are rejected before the test even runs.

This pattern is everywhere in modern test frameworks. Cypress's cy.task<K extends keyof Tasks>(name: K, arg: Tasks[K]["arg"]) uses exactly this trick to keep the task name and its argument type synchronised.

How TypeScript fills in T

Step 1 of 4

Definition

function getFirst<T>(items: T[]): T — TypeScript registers T as a type parameter. No specific type yet.

The substitution step is the magic. The same definition produces a different signature at every call site — and every signature is fully type-checked.

A QA-shaped test data builder

Bringing constraints, multiple parameters, and keyof together into one practical helper:

interface HasId { id: string | number }
 
function makeFactory<T extends HasId>(template: T) {
  return function build(overrides: Partial<T> = {}): T {
    return { ...template, ...overrides };
  };
}
 
interface User extends HasId { name: string; role: "admin" | "tester" }
 
const buildUser = makeFactory<User>({ id: 0, name: "default", role: "tester" });
 
const alice = buildUser({ id: 1, name: "Alice", role: "admin" });
//    ^ alice is User — every override field type-checked
 
const broken = buildUser({ banana: 42 });
// ❌ Object literal may only specify known properties, and 'banana' does not exist in type 'Partial<User>'.

makeFactory returns a new function (a higher-order function from chapter 4) that closes over the template. The returned function carries the T from the outer call into its own signature. Every test data factory you'll write follows roughly this shape.

⚠️ Common mistakes

  • Forgetting to constrain T when the body needs a specific property. Inside function findById<T>(items: T[], id: string), item.id is a compile error because T is "anything." Add T extends HasId and the body type-checks. The constraint is the contract that lets the body do its job.
  • Over-constraining T to a concrete type. function pick<T extends User>(items: T[]) works for User but not for Product. The point of generics is parametric reuse — constrain on a shape (an interface), not a specific entity.
  • Writing a generic that doesn't actually use T. function logAll<T>(items: T[]): void { items.forEach((i) => console.log(i)); } — this could just be items: unknown[]. If T doesn't appear in two or more places (linking input to output, or input to constraint), you don't need a generic. Generics relate types; standalone Ts are noise.

🎯 Practice task

Build a generic CRUD helper. 25-35 minutes.

  1. In your ts-for-qa/src folder, create crud.ts.
  2. Define interface HasId { id: string | number }. Add interface User extends HasId { name: string; email: string } and interface Product extends HasId { sku: string; price: number }.
  3. Write function findById<T extends HasId>(items: T[], id: string | number): T | undefined. Call it on a User array and a Product array. Confirm the return type matches the input element type.
  4. Write function getProperty<T, K extends keyof T>(obj: T, key: K): T[K]. Try getProperty(user, "name") (compiles), then getProperty(user, "age") (errors). Read the error.
  5. Write function mapItems<TInput, TOutput>(items: TInput[], fn: (item: TInput) => TOutput): TOutput[]. Use it to extract the name from users (returns string[]) and the price from products (returns number[]).
  6. Trigger every constraint check. After each, read the error and revert:
    • Call findById(["a", "b"], 1) — strings don't satisfy HasId.
    • Add interface Plain { value: string } (no id) and try findById<Plain>([{ value: "x" }], 1).
  7. Stretch: write makeFactory<T extends HasId>(template: T) exactly as in the lesson. Use it to build three factories (User, Product, Order) and confirm each one's output is fully typed without you re-stating the shape.

The next lesson moves from generic functions to generic interfaces and classes — how ApiResponse<T> becomes the one shape that fits every endpoint in your test suite.

// tip to track lessons you complete and pick up where you left off across devices.