env.dev

TypeScript Generics: A Complete Guide

Master TypeScript generics, constraints, utility types, mapped types, and conditional types with practical examples.

Last updated:

TypeScript generics let you write reusable, type-safe code that works across multiple types without resorting to any. They are the foundation of TypeScript's type system — powering utility types, collection APIs, and library typings. Generics eliminate duplicate type declarations while preserving full compile-time safety.

What Are Generics and Why Use Them?

A generic is a type parameter — a placeholder that gets filled in when the function, class, or type is used. Instead of writing separate functions for each type, you write one function that accepts a type variable.

Basic generic function
// Without generics — loses type information
function identity(value: any): any {
  return value;
}

// With generics — preserves the exact type
function identity<T>(value: T): T {
  return value;
}

const num = identity(42);       // type: number
const str = identity('hello');  // type: string

How Do Generic Constraints Work?

Use extends to constrain a generic to types that satisfy a condition. This lets you access properties safely while keeping the function generic.

Constraining generics with extends
interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(item: T): T {
  console.log(item.length);
  return item;
}

logLength('hello');      // OK — string has .length
logLength([1, 2, 3]);   // OK — array has .length
// logLength(42);        // Error — number has no .length

// Multiple constraints using intersection
function merge<T extends object, U extends object>(a: T, b: U): T & U {
  return { ...a, ...b };
}

// keyof constraint — restrict to valid keys
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: 'Alice', age: 30 };
getProperty(user, 'name');  // type: string
// getProperty(user, 'email'); // Error — 'email' is not in keyof typeof user

Which Built-in Utility Types Should You Know?

TypeScript ships with utility types that transform existing types. These are all implemented with generics internally.

UtilityEffectExample
Partial<T>Makes all properties optionalPartial<User>
Required<T>Makes all properties requiredRequired<Config>
Pick<T, K>Selects a subset of propertiesPick<User, 'name' | 'email'>
Omit<T, K>Removes specified propertiesOmit<User, 'password'>
Record<K, V>Creates object type with key type K and value type VRecord<string, number>
Readonly<T>Makes all properties readonlyReadonly<State>
Utility types in practice
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}

// Update function accepts partial user data
function updateUser(id: number, data: Partial<User>): void { /* ... */ }
updateUser(1, { name: 'Bob' }); // OK — only name is required

// Public-facing type without sensitive fields
type PublicUser = Omit<User, 'password'>;

// Lookup table keyed by user ID
type UserMap = Record<number, User>;

// Form state where everything is required
type UserForm = Required<Pick<User, 'name' | 'email'>>;

How Do Mapped Types Work?

Mapped types iterate over the keys of a type and transform each property. They use the in keyof syntax and are the mechanism behind Partial, Required, and Readonly.

Custom mapped types
// How Partial<T> works internally
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// Make all properties nullable
type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

// Prefix all keys with 'get'
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface Config {
  host: string;
  port: number;
}

type ConfigGetters = Getters<Config>;
// { getHost: () => string; getPort: () => number }

What Are Conditional Types and infer?

Conditional types choose a type based on a condition, using the T extends U ? X : Y syntax. Combined with infer, they can extract types from complex structures.

Conditional types and infer
// Basic conditional type
type IsString<T> = T extends string ? true : false;

type A = IsString<'hello'>; // true
type B = IsString<42>;      // false

// Extract the return type of a function (how ReturnType<T> works)
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;

type FnReturn = MyReturnType<() => string>; // string

// Extract element type from an array
type ElementOf<T> = T extends (infer E)[] ? E : never;

type Item = ElementOf<string[]>; // string

// Extract promise resolved type
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;

type Result = Awaited<Promise<Promise<number>>>; // number

// Distributive conditional types — unions are distributed automatically
type ToArray<T> = T extends unknown ? T[] : never;

type Distributed = ToArray<string | number>; // string[] | number[]

Tip: Use NoInfer<T> (TypeScript 5.4+) to prevent a type parameter position from contributing to inference, forcing callers to provide the type explicitly.

Generic Patterns in Real-World Code

Generics appear everywhere in production TypeScript — API clients, state management, event emitters, and validation libraries all rely on them.

Practical generic patterns
// Type-safe API client
async function fetchJson<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<T>;
}

const users = await fetchJson<User[]>('/api/users');

// Type-safe event emitter
type EventMap = {
  login: { userId: string };
  logout: undefined;
  error: { message: string; code: number };
};

function on<K extends keyof EventMap>(
  event: K,
  handler: (payload: EventMap[K]) => void
): void { /* ... */ }

on('login', (payload) => {
  console.log(payload.userId); // fully typed
});

// Builder pattern with generics
class QueryBuilder<T> {
  where<K extends keyof T>(key: K, value: T[K]): this { return this; }
  select<K extends keyof T>(...keys: K[]): Pick<T, K>[] { return []; }
}

new QueryBuilder<User>()
  .where('name', 'Alice')
  .select('name', 'email');

Key Takeaways

  • • Generics preserve type information across function calls — avoid any
  • • Use extends to constrain type parameters to specific shapes
  • • Built-in utility types (Partial, Pick, Omit, Record) cover most transformation needs
  • • Mapped types let you transform all properties of a type programmatically
  • • Conditional types with infer can extract types from complex structures
  • • Generic constraints with keyof ensure only valid property names are accepted

Frequently Asked Questions

What are TypeScript generics?

Generics let you write reusable code that works with multiple types. Instead of using "any", generics preserve type safety by letting the caller specify the type: function identity<T>(arg: T): T.

What are generic constraints?

Constraints limit what types a generic can accept using extends: function getLength<T extends { length: number }>(arg: T): number. This ensures the argument has a length property.

What are mapped types?

Mapped types create new types by transforming properties of an existing type. Example: type Readonly<T> = { readonly [K in keyof T]: T[K] }. Built-in utility types like Partial and Required are mapped types.

Was this helpful?

Stay up to date

Get notified about new guides, tools, and cheatsheets.