Skip to content

TypeScript Generics in Production: What I Actually Reach For

TypeScript Generics in Production: What I Actually Reach For

Confession: I avoided TypeScript generics for the first eight months I used the language. I’d write function getUser(id: string): any and tell myself I’d fix it later. I never did. Then I read a PR from a coworker who’d taken twelve lines of my code, replaced it with three lines and one generic, and made the autocomplete actually work. That was the moment.

Generics are one of those things where the docs make them look harder than they are, and once they click, you wonder why you were so afraid. I’ve been writing TypeScript professionally for years now, and there are three patterns I reach for over and over. I want to write them down, partly so the juniors on my team have something to point at, partly because writing them down helps me notice when I’m overusing them.

If you want the canonical syntax reference, the TypeScript handbook on generics covers it. This is the field guide.

Pattern 1: keep the input type through the function

The most common place generics actually pay off: a helper that takes some object and returns part of it. Without a generic, the type information at the call site disappears.

Here’s the bad version I see in code review at least once a week:

function pick(obj: any, keys: string[]): any {
  const out: any = {};
  for (const k of keys) out[k] = obj[k];
  return out;
}

It compiles. It’s also a lie. The caller has no idea what they got back, autocomplete dies, typos slip through. Three anys in one function is the kind of code smell I learned to spot from a mile away.

The generic version:

function pick<T extends object, K extends keyof T>(
  obj: T,
  keys: K[],
): Pick<T, K> {
  const out = {} as Pick<T, K>;
  for (const k of keys) out[k] = obj[k];
  return out;
}

Call it with pick(user, ['email', 'name']) and you get back { email: string; name: string }. Try pick(user, ['emial']) and the compiler stops you. That’s it.

The two pieces doing the work: K extends keyof T forces the keys to actually exist on the input, and the built-in Pick<T, K> utility type builds the return shape so I don’t have to. I use this exact pattern for response slicing and prop forwarding in React. Anywhere I’m passing a subset of an object through.

One trick worth knowing: if you call pick(user, ['email', 'name']), TypeScript infers K as the literal union 'email' | 'name', not string. That literal inference is what makes the return type tight. If you ever wrap this call in a function that takes string[] and forwards it, you lose the literal union and the safety collapses back to Record<string, unknown>-ish. The lesson I learned the hard way: don’t widen too early.

Pattern 2: derive the output type with infer

This is the one that took me longest to like. The infer keyword looked cryptic until I had a real problem that needed it.

The problem was a thin wrapper that unwrapped a Promise if you handed it one, and passed through the value otherwise. Writing the runtime is easy. Writing the return type is where I got stuck.

type Awaited<T> = T extends Promise<infer U> ? U : T;

Read it slowly. “If T looks like Promise, give me that something. Otherwise give me T.” That’s the whole shape. Conditional types pattern-match like a switch statement, and infer is how you grab the piece you need.

Awaited is now in the standard lib, so you don’t have to write it yourself. But once that shape clicked for me, every conditional type I’d seen in library code suddenly made sense.

A more real example, an event bus where each handler gets the right payload typed automatically:

type Events = {
  'user:created': { id: string; email: string };
  'order:shipped': { orderId: string; tracking: string };
  'payment:failed': { orderId: string; reason: string };
};

function on<E extends keyof Events>(
  event: E,
  handler: (payload: Events[E]) => void,
) {
  // wires through to whatever event emitter you use
}

on('user:created', (payload) => {
  // payload is { id: string; email: string }
  console.log(payload.email);
});

Add a new event to the Events map and every call site picks up the right handler type. The compiler walks the map for you, no casts needed.

Another infer pattern I reach for: pulling the argument type out of an existing function so I don’t have to retype it.

type FirstArg<F> = F extends (arg: infer A, ...rest: any[]) => any ? A : never;

function logSomeServiceCall(arg: FirstArg<typeof someService.create>) {
  console.log('would call with', arg);
}

If someService.create changes signature, logSomeServiceCall follows along. Without infer, I’d be duplicating the input type and watching it drift.

For the deeper end of conditional types, the TypeScript page on conditional types covers infer properly, including distributive conditional types which I’ll spare you here.

Pattern 3: constraints that catch the wrong shape

I used to write generics with no constraint, then add runtime checks to compensate. Wrong order. The constraint is the check.

Tiny example. I needed a helper that turned a list of items into a map keyed by id:

function byId<T extends { id: string }>(items: T[]): Record<string, T> {
  const out: Record<string, T> = {};
  for (const item of items) out[item.id] = item;
  return out;
}

The extends { id: string } part is the whole point. Hand it an array of items without an id field, the compiler stops you before the code runs. Hand it User[] and you get Record<string, User> back. No runtime guard needed.

The mistake I see in junior code: people write T extends any[] or T extends object thinking they’re being permissive. A constraint of object is barely tighter than no constraint at all. The useful constraints are specific shapes like { id: string } or Record<string, unknown>.

Rule of thumb I use: if the constraint isn’t telling the compiler something it couldn’t already infer, drop it.

Where I stop using generics

This is the part most tutorials skip. There’s a phase after you learn generics where you start sprinkling them everywhere, even on functions that only ever see one type. Code review gets slower because every signature now requires you to mentally substitute <T> to read the body.

Places I now actively delete generics. Internal helpers with one caller: if processOrder is only ever called with Order, just type the parameter Order. The generic adds noise without adding safety.

React components where the prop type doesn’t actually vary. I keep seeing <Button<T>> style components where the generic is never bound to anything dynamic. Use a union, or write two components.

Functions where the generic is decorative. If the constraint is so loose that the caller can pass anything, the generic isn’t earning its keep. Same with output types: when the return type is T | undefined regardless of what T is, the generic doesn’t add information.

The test I run on myself before adding <T>: would deleting the generic make the call site less safe? If the answer is no, the generic is decoration.

A concrete refactor I did last quarter. I had a helper that looked like this:

function logEvent<T>(name: string, payload: T): void {
  analytics.track(name, payload);
}

Looked clever. Did nothing. The constraint on T was missing, so payload could be anything, and there was no return type to derive. Every call site already typed the payload object literal. I deleted the <T> and typed payload: Record<string, unknown>. Same safety, less syntax. Nobody on the team noticed because nothing changed for them.

I covered something adjacent to this in my post on the satisfies operator. A lot of the time, the cleaner answer is a normal type plus satisfies, not a generic at all. If you’ve been reaching for <T> reflexively, that one’s worth a read too.

What to try this week

Pick one helper in your codebase that currently uses any or returns a loosely-typed object. Rewrite it using Pattern 1, the one with T extends object, K extends keyof T. Read the call site the next morning. If autocomplete improved and the function got safer, keep it. If the signature got harder to read without buying anything, revert.

Generics are a tool for keeping type information accurate when data flows through helpers. They aren’t a status symbol. The three patterns here cover most of the TypeScript I actually ship. If you want to see the kind of codebases I apply this stuff in, my project work is here.