TypeScript Generics: A Practical Guide With Real Examples

TypeScript Generics: A Practical Guide With Real Examples

Generics separate TypeScript beginners from everyone else. Here are the patterns you'll actually reach for in real codebases, explained without the academic fluff.

Here's the thing about TypeScript generics -- most people overthink them. You see <T> in someone's code and your brain goes "ah, computer science stuff, I'll deal with that later." But generics are just a way of saying "I don't know what type this will be yet, but I want TypeScript to keep track of it for me." That's it. Once that clicks, the rest is just patterns you pick up over time.

I avoided generics for my first six months of writing TypeScript. I'd see them in library type definitions and just nod along, pretending I understood. The turning point was building a data fetching layer where I had the same response-parsing logic duplicated across 30 endpoints. Each copy was identical except for the types. That was the moment I realized generics aren't academic -- they're the tool that keeps you from copy-pasting type definitions until your codebase collapses under its own weight.

The Problem Generics Actually Solve

Say you need a function that wraps any value in an array. You've got two options, and they both stink:

// Option 1: Lose type information
function wrapInArray(value: any): any[] {
  return [value];
}

// Option 2: Write a function for every type
function wrapStringInArray(value: string): string[] {
  return [value];
}
function wrapNumberInArray(value: number): number[] {
  return [value];
}

Option 1 throws away everything TypeScript gives you. Option 2 is just... no. Nobody's maintaining that. Generics let you write one function that keeps the type intact:

function wrapInArray<T>(value: T): T[] {
  return [value];
}

const strings = wrapInArray('hello'); // string[]
const numbers = wrapInArray(42);      // number[]

TypeScript infers T from whatever you pass in. You don't even need to write wrapInArray<string>('hello') -- it figures it out. That inference is doing a lot of heavy lifting, and you'll lean on it constantly.

Generic Functions in Practice

The wrapInArray example is clean but a bit artificial. Here's something closer to what you'd actually write. I've got a caching utility that stores anything but gives you back the correct type:

const cache = new Map<string, unknown>();

function cacheGet<T>(key: string): T | undefined {
  return cache.get(key) as T | undefined;
}

function cacheSet<T>(key: string, value: T, ttlMs?: number): void {
  cache.set(key, value);
  if (ttlMs) {
    setTimeout(() => cache.delete(key), ttlMs);
  }
}

// Usage
cacheSet<User>('user:123', { id: 123, name: 'Anurag', email: '[email protected]' });
const user = cacheGet<User>('user:123'); // type: User | undefined

Notice that with cacheSet, TypeScript can infer T from the value you pass. But with cacheGet, there's no value to infer from -- you're reading, not writing. So you have to specify the type explicitly. Knowing when inference works and when you need to be explicit is a skill you develop over time. The rule of thumb: if TypeScript can see the value, it can infer the type. If it can't, you have to tell it.

Another real-world pattern: a generic retry function. I use this for flaky HTTP calls and database connections:

async function retry<T>(
  fn: () => Promise<T>,
  attempts: number = 3,
  delayMs: number = 1000
): Promise<T> {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      if (i === attempts - 1) throw err;
      await new Promise(r => setTimeout(r, delayMs * (i + 1)));
    }
  }
  throw new Error('Unreachable');
}

// TypeScript knows this returns Promise<User>
const user = await retry(() => fetchUser(123));

// And this returns Promise<string>
const html = await retry(() => fetch(url).then(r => r.text()));

The return type of retry matches whatever the function you pass in returns. You don't have to annotate it -- TypeScript traces the type through the generic. That's the power move here: you write the retry logic once and it preserves type information for every call site.

Generic Interfaces (The Pattern You'll Use Every Day)

This is where generics start paying rent. Here's an API response wrapper. I've used some version of this in every TypeScript project I've worked on:

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: Date;
}

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

async function fetchUser(id: number): Promise<ApiResponse<User>> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

// TypeScript knows exactly what data contains
const result = await fetchUser(1);
console.log(result.data.name); // Fully typed!

Without that generic, you'd either type every API function's response separately (copy-paste city) or use any and lose autocomplete. With it, you write the wrapper once and reuse it everywhere. ApiResponse<User>, ApiResponse<Product[]>, ApiResponse<{token: string}> -- same structure, different payloads, full type safety.

Here's an extension of this pattern that I reach for in bigger projects -- a paginated response type:

interface PaginatedResponse<T> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
  hasMore: boolean;
}

async function fetchUsers(page: number): Promise<PaginatedResponse<User>> {
  const res = await fetch(`/api/users?page=${page}`);
  return res.json();
}

const result = await fetchUsers(1);
result.data.forEach(user => {
  console.log(user.name); // TypeScript knows user is User
});
console.log(result.hasMore); // boolean

Same idea, different shape. You define the pagination structure once, and then PaginatedResponse<Product>, PaginatedResponse<Order> -- they all just work. If your pagination structure ever changes (say you add a totalPages field), you update one interface and every consumer gets the update.

Constraints: When T Can't Be Just Anything

Sometimes you need your generic to be a little more specific. Like, "I don't care what type you pass, but it better have a .length property." That's what extends does:

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(item: T): void {
  console.log(`Length: ${item.length}`);
}

logLength('hello');     // OK - string has length
logLength([1, 2, 3]);   // OK - array has length
logLength({ length: 5 }); // OK - object has length
// logLength(42);       // Error - number has no length

The extends keyword here isn't inheritance. It's a constraint. You're telling TypeScript "T must be at least this shape." It can be more, but not less.

I use constraints most often with object types. For example, a generic function that only works with objects that have an id field:

interface HasId {
  id: number | string;
}

function findById<T extends HasId>(items: T[], id: T['id']): T | undefined {
  return items.find(item => item.id === id);
}

const users = [
  { id: 1, name: 'Anurag', email: '[email protected]' },
  { id: 2, name: 'Priya', email: '[email protected]' }
];

const user = findById(users, 1);
// user is { id: number; name: string; email: string } | undefined

TypeScript doesn't just know that user has an id -- it knows the full shape, including name and email. The constraint guarantees a minimum shape, but the generic preserves everything else. That distinction matters.

This pairs well with keyof, which is probably the single most useful generic pattern in day-to-day TypeScript:

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

const user = { name: 'Anurag', age: 25, email: '[email protected]' };

const name = getProperty(user, 'name');  // type: string
const age = getProperty(user, 'age');    // type: number
// getProperty(user, 'phone');           // Error!

That last line is the whole point. TypeScript catches the typo at compile time, not at 2 AM in production. K extends keyof T means "K must be one of the keys of T." So if T is {name: string, age: number}, then K can only be 'name' or 'age'. Try to pass 'phone' and the compiler yells at you.

Here's a practical twist on this pattern -- a type-safe event emitter:

type EventMap = {
  userCreated: { userId: string; email: string };
  orderPlaced: { orderId: string; total: number };
  error: { message: string; code: number };
};

class TypedEmitter<Events extends Record<string, any>> {
  private handlers: Partial<Record<keyof Events, Function[]>> = {};

  on<K extends keyof Events>(event: K, handler: (data: Events[K]) => void): void {
    if (!this.handlers[event]) this.handlers[event] = [];
    this.handlers[event]!.push(handler);
  }

  emit<K extends keyof Events>(event: K, data: Events[K]): void {
    this.handlers[event]?.forEach(fn => fn(data));
  }
}

const emitter = new TypedEmitter<EventMap>();

emitter.on('userCreated', (data) => {
  console.log(data.email); // TypeScript knows this is string
});

emitter.on('orderPlaced', (data) => {
  console.log(data.total); // TypeScript knows this is number
});

// emitter.emit('userCreated', { orderId: '123' }); // Error! Wrong payload shape

This is one of those patterns where generics go from "nice convenience" to "genuinely preventing bugs." The event name and its payload shape are linked. Emit a userCreated event with the wrong data shape and the compiler stops you. I've used this exact pattern to replace a loosely typed event system that was a constant source of runtime errors.

Built-in Utility Types (Free Generics You Already Have)

TypeScript ships with a bunch of generic utility types. You don't need to build everything from scratch. Here are the ones I use all the time:

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

// Make all properties optional
type UpdateUser = Partial<User>;

// Pick specific properties
type PublicUser = Pick<User, 'id' | 'name' | 'email'>;

// Exclude specific properties
type UserWithoutPassword = Omit<User, 'password'>;

// Make all properties required
type RequiredUser = Required<Partial<User>>;

// Record type for dictionaries
type UserRoles = Record<string, 'admin' | 'user' | 'editor'>;

Partial is the one you'll reach for most often -- any time you have an update endpoint where the client might send only some fields. Pick and Omit are great for creating "views" of your data. Got a User type but need to strip the password before sending it to the client? Omit<User, 'password'>. Done.

Record trips people up sometimes. It's just a typed dictionary. Record<string, number> means "an object where every key is a string and every value is a number." Useful for lookup tables, caches, that sort of thing.

One utility type that's underrated: Extract and Exclude. They filter union types. Say you've got a union of event names and you want to narrow it down:

type AllEvents = 'click' | 'scroll' | 'keydown' | 'keyup' | 'focus' | 'blur';

type KeyboardEvents = Extract<AllEvents, 'keydown' | 'keyup'>;
// type KeyboardEvents = 'keydown' | 'keyup'

type NonKeyboardEvents = Exclude<AllEvents, 'keydown' | 'keyup'>;
// type NonKeyboardEvents = 'click' | 'scroll' | 'focus' | 'blur'

I used Exclude recently to create a type that represents "all the fields on a User except the ones generated by the database." The insert type only accepts the fields you actually control:

interface User {
  id: number;          // auto-generated
  createdAt: Date;     // auto-generated
  updatedAt: Date;     // auto-generated
  name: string;
  email: string;
}

type AutoFields = 'id' | 'createdAt' | 'updatedAt';
type InsertUser = Omit<User, AutoFields>;
// { name: string; email: string }

Readable, maintainable, and if you add a new auto-generated field, you add it to one union and every insert type updates automatically.

Mistakes I Made (So You Don't Have To)

A few things that tripped me up when I was learning generics:

Over-genericizing. Not everything needs to be generic. If a function only ever takes a User, just type it as User. I went through a phase where I made everything generic because it felt clever. It wasn't. It made the code harder to read for no benefit. A generic should earn its place by being used with multiple types.

Naming generics with single letters. T is fine for simple cases. But when you've got three or four generics in a signature, <T, U, V, W> is unreadable. Give them meaningful names:

// Hard to follow
function merge<T, U>(a: T, b: U): T & U { ... }

// Much clearer
function merge<Base, Extension>(a: Base, b: Extension): Base & Extension { ... }

Forgetting that generics can have defaults. Just like function parameters, generics can have default values:

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

// No need to specify T if you don't care about the payload type
const response: ApiResponse = { data: 'anything', status: 200 };

// But you can narrow it when you do
const userResponse: ApiResponse<User> = { data: user, status: 200 };

Defaults reduce friction at call sites where the caller doesn't need to be specific. It's a small thing, but it adds up across a codebase.

Where to Go From Here

You don't need to understand conditional types and mapped types and infer keywords right away. Honestly, most application code never touches that stuff -- it's library-author territory. What you need is the stuff above: generic functions, generic interfaces, constraints with extends, keyof patterns, and the built-in utility types. That covers probably 90% of real-world use cases.

If you're looking for a way to practice, take an existing project and find every place you've used any. Most of those can be replaced with a generic. Your ApiResponse<T> wrapper alone will probably clean up a dozen files. And the autocompletion you get back is worth the 10 minutes it takes to set up.

The real test is this: if you can read a library's type signature and understand what it expects, you're there. You don't need to write type DeepPartial<T> = ... from memory. You need to look at useState<User | null>(null) and instantly get why that generic is there and what it's doing. The patterns in this post will get you to that point. Everything beyond is gravy.

Written by Anurag Kumar

Full-stack developer passionate about Node.js and building fast, scalable web applications. Writing about what I learn every day.

Comments (1)

Sneha Reddy

The ApiResponse generic interface is exactly what I needed. Been copy-pasting type definitions for every endpoint. This cleans things up massively.