What Are Generics and Why They Matter

8 min read

You've reached the chapter most TypeScript learners quietly worry about. Take a breath — it's simpler than it looks. Generics are how you write a function (or interface, or class) once and have it work safely for many types. They're how Cypress's cy.get<HTMLInputElement>(...) keeps the right element type, how Array<T> keeps strings as strings and numbers as numbers, and how the typed retry helper from the last chapter remembered what it returned. This lesson starts at the very bottom and builds up slowly.

The problem generics solve

You write a helper that returns the first item of an array of users:

function getFirstUser(items: User[]): User {
  return items[0];
}

Then you need the same thing for products. And orders. And test cases. You don't want to copy-paste four nearly-identical functions. The first instinct is to use any:

function getFirst(items: any[]): any {
  return items[0];
}
 
const first = getFirst(users);
// first is `any` — no autocomplete, no type checking 😢
first.name;       // compiles
first.banana;     // also compiles, also wrong

any makes the function reusable but throws away every type guarantee. The caller has lost the link between "I passed a User[]" and "I should get a User back." This is exactly the problem TypeScript was supposed to fix.

The fix: a type parameter

A generic function takes a type as an argument, the same way a regular function takes a value as an argument. You write <T> after the name to declare a type parameter, then use T wherever you'd otherwise write a concrete type:

function getFirst<T>(items: T[]): T {
  return items[0];
}
 
const user    = getFirst(users);     // T inferred as User    → return type User
const product = getFirst(products);  // T inferred as Product → return type Product
const code    = getFirst([200, 201]); // T inferred as number  → return type number
 
user.name;      // ✅ autocomplete — TypeScript knows it's a User
user.banana;    // ❌ Property 'banana' does not exist on type 'User'.

The function definition stays the same; only the type parameter changes per call. Type safety preserved, code reused. That's the whole pitch.

A useful analogy: the mould

Think of a generic function like a mould for shaping things.

  • The mould (the function logic — "return the first item") is the same regardless of what you pour in.
  • The material (the actual type — User, Product, number) changes per use.
  • TypeScript holds the mould steady and tracks which material you used, so it knows the shape of the thing that comes out.

A non-generic function is a single-purpose mould that only takes plaster. any is a sloppy mould that produces shapeless blobs. A generic function is a parametric mould — strong enough to hold shape, flexible enough to take anything.

How TypeScript figures out T

You almost never need to specify T explicitly. TypeScript infers it from the arguments at the call site:

getFirst(users);              // inferred: T = User
getFirst([200, 201, 404]);    // inferred: T = number
getFirst(["a", "b"]);         // inferred: T = string

When you want to be explicit — for documentation, or because inference can't figure it out — pass the type in angle brackets:

getFirst<Product>(products);   // explicit: T = Product

Inferred is the default; explicit is the override. Both produce identical code at runtime — generics are erased after compilation, just like every other type.

T is just a name — pick what reads best

T is convention (it stands for "Type") and the right default. But it's just a name — you can use anything that's readable in context:

function findById<TItem>(items: TItem[], id: string): TItem | undefined { /* ... */ }
function map<TInput, TOutput>(items: TInput[], fn: (x: TInput) => TOutput): TOutput[] { /* ... */ }

For helpers with a single type parameter, plain T is fine. For multiple parameters, descriptive names (TKey, TValue, TInput, TOutput) earn their length. Long names are noise on a one-letter helper but documentation on a four-parameter signature.

Generics are type arguments

The cleanest mental model: generics are like function arguments, but for types instead of values.

  • Regular function: getFirst(items)items is a value argument.
  • Generic function: getFirst<User>(items)<User> is a type argument.

Functions take values to compute over. Generic functions take types to relate input shapes to output shapes. Once you see the parallel, the angle brackets stop looking exotic.

Side by side — the difference

Same function, three implementations — what each preserves

any — no safety

  • function getFirst(items: any[]): any

  • Compiles for every input shape

  • Return type is any — no autocomplete

  • user.banana compiles silently — bug ships

  • Reusable but unsafe — defeats TypeScript

Concrete — safe but rigid

  • function getFirstUser(items: User[]): User

  • Type-safe for User arrays

  • Doesn't work for Product, Order, anything else

  • You'd write four near-identical helpers

  • Safe but full of duplication

Generic — safe AND reusable

  • function getFirst<T>(items: T[]): T

  • T inferred per call from the argument

  • Return type matches the input element type

  • Autocomplete works — wrong properties rejected

  • One helper, every type, full safety

A first QA-shaped example

A typed test data builder that creates and validates a fixture object:

function makeFixture<T>(template: T, overrides: Partial<T> = {}): T {
  return { ...template, ...overrides };
}
 
interface User { id: number; name: string; role: "admin" | "tester" | "viewer" }
 
const defaultUser: User = { id: 0, name: "default", role: "tester" };
 
const alice = makeFixture(defaultUser, { name: "Alice", role: "admin" });
//    ^ alice is User — overrides type-checked against User's fields
 
const broken = makeFixture(defaultUser, { name: "Alice", banana: 42 });
// ❌ Object literal may only specify known properties, and 'banana' does not exist in type 'Partial<User>'.

Partial<T> is one of TypeScript's built-in utility types — it makes every field of T optional. You'll meet it formally in this chapter's last lesson; for now, notice how the generic T and the Partial<T> together produce a fully type-safe factory that works for any fixture shape.

⚠️ Common mistakes

  • Using any to avoid generics. function getFirst(items: any[]): any is the giveaway — the function works on any array, but the caller loses every type guarantee. Almost any place you reach for any because "the function is reusable" is a place generics fit better.
  • Specifying T explicitly when inference would do. getFirst<User>(users) is correct but redundant — TypeScript reads User[] and infers T = User. Explicit type arguments earn their place when inference can't figure it out (rare) or when you want documentation; otherwise, let inference work.
  • Treating <T> as runtime metadata. Generics are erased at compile time. There is no T at runtime — you cannot do if (T === User) { ... } or new T(). If you need runtime knowledge of the type, pass a value-level argument (a class reference, a tag string) explicitly.

🎯 Practice task

Write your first generic. 20-30 minutes.

  1. In your ts-for-qa/src folder, create generics.ts.
  2. Write a non-generic version first: function getFirstUser(items: User[]): User. Hard-code an array of three users and confirm autocomplete on the result.
  3. Now make it generic: function getFirst<T>(items: T[]): T. Call it with the same users array, an array of numbers, and an array of strings. Confirm the return type changes per call (hover over each result in VS Code).
  4. Trigger every key check. After each, read the error and revert:
    • Pass an empty array — what's the runtime risk? (Hint: the type says T, but the runtime returns undefined.)
    • Try getFirst(123) (not an array).
    • Set const x: number = getFirst(users).
  5. Write function logAndReturn<T>(label: string, value: T): T that prints the label and value and returns value. Use it to wrap two API calls of different return shapes — confirm each caller still gets the precise type.
  6. Stretch: rewrite the makeFixture<T> example. Pass it three different default objects (User, Product, Order) and confirm each call site has full autocomplete on the overrides.

The next lesson scales up: multiple type parameters, generic constraints, and the keyof operator that lets you write type-safe getProperty helpers.

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