TypeScript Generics Explained

TypeScript Generics Explained

Mykola Nesterchuk - March 4, 2026

#typescript
#javascript

Introduction

Generics are one of those things in TypeScript that look scary at first - angle brackets everywhere, mysterious Ts and Us floating around - but once it finally clicks, going back feels impossible.

The idea is simple: write code that works with any type without throwing type safety out the window. Think of it as a variable, but for types instead of values.

The Problem Generics Solve

Say you want a function that returns the first item of an array. Without generics you're stuck choosing between two bad options:

// too loose - you lose all type information function first(arr: any[]): any { return arr[0]; } // too rigid - only works for numbers function first(arr: number[]): number { return arr[0]; }

Generics let you have it both ways:

function first<T>(arr: T[]): T { return arr[0]; } const num = first([1, 2, 3]); // number const str = first(['a', 'b', 'c']); // string

T is just a placeholder. TypeScript figures out what it should be from whatever you pass in.

Generic Interfaces

Generics work in interfaces too, which comes in handy when you're describing shapes that wrap different kinds of data:

interface ApiResponse<T> { data: T; status: number; message: string; } interface User { id: number; name: string; } interface Post { slug: string; title: string; } const userResponse: ApiResponse<User> = { data: { id: 1, name: 'Mykola' }, status: 200, message: 'OK', }; const postResponse: ApiResponse<Post> = { data: { slug: 'typescript-generics', title: 'TypeScript Generics Explained' }, status: 200, message: 'OK', };

You'll see this pattern constantly when working with APIs.

Generic Constraints

Sometimes you want to accept any type, but only if it has certain properties. That's what extends is for:

// TypeScript has no idea T has a .length - this breaks function logLength<T>(value: T): void { console.log(value.length); } // now it knows function logLength<T extends { length: number }>(value: T): void { console.log(value.length); } logLength('hello'); // fine, strings have .length logLength([1, 2, 3]); // fine, arrays too logLength(42); // nope

The keyof Constraint

One of the more useful patterns - reading a property from an object without guessing:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user = { id: 1, name: 'Mykola', role: 'admin' }; const name = getProperty(user, 'name'); // string const id = getProperty(user, 'id'); // number const bad = getProperty(user, 'email'); // compile error, doesn't exist

K extends keyof T means K can only be one of the actual keys of T. Whole category of bugs gone.

Generic Classes

Classes can be generic too. A stack is the classic example:

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[this.items.length - 1]; } get size(): number { return this.items.length; } } const numStack = new Stack<number>(); numStack.push(1); numStack.push(2); console.log(numStack.pop()); // 2 const strStack = new Stack<string>(); strStack.push('hello'); strStack.push(42); // compile error

Multiple Type Parameters

Nothing stops you from using more than one. Useful when input and output are different types:

function pair<A, B>(first: A, second: B): [A, B] { return [first, second]; } const result = pair('age', 30); // [string, number]

Or when mapping one type to another:

function map<T, U>(arr: T[], transform: (item: T) => U): U[] { return arr.map(transform); } const lengths = map(['hello', 'world', 'ts'], s => s.length); // number[]

Default Type Parameters

Type parameters can have defaults, same as function arguments:

interface PaginatedResponse<T, E = string> { items: T[]; total: number; error?: E; } // E defaults to string const posts: PaginatedResponse<Post> = { items: [], total: 0, }; // override it with something more specific interface ApiError { code: number; message: string; } const users: PaginatedResponse<User, ApiError> = { items: [], total: 0, error: { code: 404, message: 'Not found' }, };

Practical Example: A Typed fetch Wrapper

Here's something you can actually drop into a project:

async function fetchJson<T>(url: string): Promise<T> { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } return response.json() as Promise<T>; } const user = await fetchJson<User>('/api/users/1'); console.log(user.name); // TypeScript knows it's a string const posts = await fetchJson<Post[]>('/api/posts'); posts.forEach(post => console.log(post.slug));

Summary

Once generics make sense, you start seeing places for them everywhere:

  • <T> - a type placeholder filled in when the function is called
  • extends - limit which types are allowed
  • keyof - limit to actual keys of another type
  • <T, U> - when input and output types differ
  • <T = string> - default type for when nothing is passed

A good rule of thumb: every time you reach for any, or find yourself writing nearly identical functions for different types, there's probably a generic waiting to be written.

Table of Contents