Back to JavaScript and TypeScript

TypeScript Generics

Generics let you write one function or type that works safely with any type. This guide starts from zero and builds up to real patterns used in production code.

Section

What Are Generics?

Generics solve the copy-paste problem write one function or type that works safely with any type, instead of one version per type.

5 concepts

// Problem: without generics you copy-paste the same logic per type
function wrapString(value: string): { value: string } { return { value } }
function wrapNumber(value: number): { value: number } { return { value } }
function wrapBoolean(value: boolean): { value: boolean } { return { value } }

// Solution: one generic function covers every type
function wrap<T>(value: T): { value: T } {
  return { value }
}

const a = wrap("hello")   // { value: string }
const b = wrap(42)         // { value: number }
const c = wrap(true)       // { value: boolean }
const d = wrap([1, 2, 3]) // { value: number[] }

Concept

Type Parameter

A placeholder for a type, written as <T> after a function or type name. TypeScript fills it in based on what you pass.

Example: In identity<T>(value: T): T, the T is the type parameter it becomes string when you call identity("hello").

Concept

Type Argument

The actual type you supply when calling a generic. You can write it explicitly as identity<string>("hello") or let TypeScript infer it.

Example: Calling wrap<number>(42) passes number as the type argument, so the return type becomes number.

Concept

Type Inference

TypeScript's ability to figure out the type argument from the value you pass, so you rarely need to write <T> explicitly at call sites.

Example: Calling identity("hello") TypeScript sees a string argument and automatically infers T = string.

Concept

Constraint

A restriction on what types T can be, written as T extends SomeType. It narrows what T can be so you can safely access properties on it.

Example: <T extends { length: number }> means T must have a length property arrays and strings both qualify.

Concept

Default Type Parameter

A fallback type used when no type argument is supplied, written as <T = DefaultType>. Works like a default function argument.

Example: <T = string> means callers can omit the type argument and T will be string unless they specify otherwise.

Section

Generic Functions

Put <T> after the function name. TypeScript infers the type argument from what you pass in you almost never need to write the angle brackets at the call site.

4 examples

Example

Identity the simplest generic

The identity function returns whatever you give it. Without generics it forces you to choose any (losing type safety) or write one version per type. With T, the input and output types are linked.

function identity<T>(value: T): T {
  return value
}

// TypeScript infers T from the argument  no need to write <string>
const name = identity("Alice")   // string
const age  = identity(30)         // number
const flag = identity(false)      // boolean

// You can also be explicit when inference needs a nudge
const result = identity<string[]>(["a", "b"])  // string[]

Example

Generic array utilities

Generic functions shine when working with arrays. The element type flows through the whole function without losing precision.

// Return the first element, or undefined if empty
function first<T>(arr: T[]): T | undefined {
  return arr[0]
}

const firstNum  = first([10, 20, 30])   // number | undefined
const firstWord = first(["a", "b"])     // string | undefined

// Return last element
function last<T>(arr: T[]): T | undefined {
  return arr[arr.length - 1]
}

// Chunk an array into groups of n
function chunk<T>(arr: T[], size: number): T[][] {
  const result: T[][] = []
  for (let i = 0; i < arr.length; i += size) {
    result.push(arr.slice(i, i + size))
  }
  return result
}

chunk([1, 2, 3, 4, 5], 2)  // number[][]  →  [[1,2],[3,4],[5]]

Example

Transforming with two type parameters

When the input and output types differ, use two type parameters. This is how the built-in Array.map works internally.

// Transform every element from type A to type B
function mapArray<A, B>(arr: A[], transform: (item: A) => B): B[] {
  return arr.map(transform)
}

const lengths = mapArray(["hello", "world"], (s) => s.length)
// number[]  →  [5, 5]

const doubled = mapArray([1, 2, 3], (n) => n * 2)
// number[]  →  [2, 4, 6]

const labels = mapArray([1, 2, 3], (n) => `item-${n}`)
// string[]  →  ["item-1", "item-2", "item-3"]

Example

Type-safe filter with type predicates

Generic functions can work with type predicates, making type guards reusable for any type.

function isDefined<T>(value: T | null | undefined): value is T {
  return value !== null && value !== undefined
}

const items = [1, null, 2, undefined, 3]
const defined = items.filter(isDefined)
// number[]  →  [1, 2, 3]

// Works the same with strings
const words = ["hello", null, "world", undefined]
const realWords = words.filter(isDefined)
// string[]  →  ["hello", "world"]

Section

Generic Interfaces and Types

Describe reusable shapes API responses, form fields, repository contracts where the core structure stays the same but the data type changes.

4 examples

Example

Generic interface Box<T>

Interfaces can carry a type parameter so every property that references T stays in sync. The interface defines the shape; the type argument fills in the blanks.

interface Box<T> {
  value: T
  label: string
}

const numberBox: Box<number> = { value: 42, label: "age" }
const stringBox: Box<string> = { value: "Alice", label: "name" }

// TypeScript catches mismatches at compile time
const wrong: Box<number> = { value: "oops", label: "x" }
// Error: Type 'string' is not assignable to type 'number'

Example

Generic type alias ApiResponse<T>

A type alias with a generic lets you describe a consistent API envelope shape while keeping the data field typed to the actual payload.

type ApiResponse<T> = {
  data: T
  status: number
  message: string
}

type User = { id: number; name: string }
type Post = { id: number; title: string; body: string }

const userResponse: ApiResponse<User> = {
  data: { id: 1, name: "Alice" },
  status: 200,
  message: "ok",
}

const postResponse: ApiResponse<Post[]> = {
  data: [{ id: 1, title: "Hello", body: "World" }],
  status: 200,
  message: "ok",
}

Example

Pair<A, B> composable generic tuples

Generic type aliases compose. A Pair<A, B> holds two related values of different types without losing type information.

type Pair<A, B> = { first: A; second: B }

// Same types
const point: Pair<number, number> = { first: 3, second: 4 }

// Different types, still safe
const entry: Pair<string, number> = { first: "Alice", second: 30 }

// Nest generics for layered shapes
type Labeled<T> = Pair<string, T>
const labeled: Labeled<boolean> = { first: "active", second: true }

Example

Generic interface extending another

Generic interfaces can extend other generics for example, a repository contract that works for any identifiable entity.

interface Identifiable {
  id: string
}

// T must have an id field (constrained by Identifiable)
interface Repository<T extends Identifiable> {
  findById(id: string): T | undefined
  findAll(): T[]
  save(item: T): void
  delete(id: string): void
}

type Product = { id: string; name: string; price: number }

declare const productRepo: Repository<Product>
const p = productRepo.findById("abc")  // Product | undefined

Section

Generic Classes

Classes take the type parameter in their declaration. Every method is then typed to whatever you pass when you instantiate.

2 examples

Example

Generic Stack<T>

A stack is the classic generic class example. Every push and pop is typed to whatever T you instantiate with completely type-safe, zero duplication.

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 }
  isEmpty(): boolean { return this.items.length === 0 }
}

const numStack = new Stack<number>()
numStack.push(1)
numStack.push(2)
numStack.pop()   // number | undefined  →  2

const strStack = new Stack<string>()
strStack.push("hello")
strStack.peek()  // string | undefined  →  "hello"

Example

Generic TypedEmitter<Events>

Type-safe event emitters use generics to map event names to their payload types. No more guessing what a "data" event carries.

type EventMap = Record<string, unknown>

class TypedEmitter<Events extends EventMap> {
  private listeners: {
    [K in keyof Events]?: Array<(payload: Events[K]) => void>
  } = {}

  on<K extends keyof Events>(event: K, listener: (payload: Events[K]) => void): void {
    if (!this.listeners[event]) this.listeners[event] = []
    this.listeners[event]!.push(listener)
  }

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

type AppEvents = {
  userJoined: { userId: string; name: string }
  messageReceived: { from: string; text: string }
}

const emitter = new TypedEmitter<AppEvents>()

emitter.on("userJoined", (payload) => {
  console.log(`${payload.name} joined`)  // fully typed
})

emitter.emit("userJoined", { userId: "u1", name: "Alice" })
// emitter.emit("userJoined", { bad: "data" })  // Error!

Section

Constraints with extends

By default TypeScript won't let you access any property on T. Write T extends SomeType to say T must have at least this shape then you can use those properties safely.

4 examples

Example

extends basic shape constraint

Without constraints, TypeScript won't let you access any property on T. With T extends { length: number }, you tell TypeScript that T definitely has a length so you can use it safely.

// Without constraint: TypeScript can't know T has .length
function getLength<T>(value: T): number {
  return value.length  // Error: Property 'length' does not exist on type 'T'
}

// With constraint: T must have a length property
function getLength<T extends { length: number }>(value: T): number {
  return value.length  // Safe!
}

getLength("hello")       // 5   string has length
getLength([1, 2, 3])     // 3   array has length
getLength({ length: 7 }) // 7   plain object works too
// getLength(42)          // Error: number has no length

Example

keyof constraint safe property access

K extends keyof T constrains K to only be keys that actually exist on T. This is the pattern behind Omit, Pick, and property accessor helpers.

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

const user = { id: 1, name: "Alice", active: true }

const name   = getProperty(user, "name")    // string
const id     = getProperty(user, "id")      // number
const active = getProperty(user, "active")  // boolean

// TypeScript rejects keys that don't exist
// getProperty(user, "email")  // Error: not assignable to keyof typeof user

Example

extends union restricting to primitive types

Constrain T to a union of types when a function only makes sense for certain primitives.

function sortArray<T extends string | number>(arr: T[]): T[] {
  return [...arr].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
}

sortArray([3, 1, 2])          // number[]  →  [1, 2, 3]
sortArray(["c", "a", "b"])    // string[]  →  ["a", "b", "c"]
// sortArray([true, false])   // Error: boolean doesn't satisfy constraint

Example

Intersection constraints multiple requirements

Constraints can combine with intersection types to require multiple capabilities at once.

interface HasId { id: string }
interface HasTimestamps { createdAt: Date; updatedAt: Date }

// T must have both id and timestamps
function formatRecord<T extends HasId & HasTimestamps>(record: T): string {
  return `[${record.id}] created ${record.createdAt.toISOString()}`
}

type Post = HasId & HasTimestamps & { title: string }
const post: Post = {
  id: "p1",
  title: "Hello",
  createdAt: new Date("2024-01-01"),
  updatedAt: new Date("2024-01-02"),
}

formatRecord(post)  // "[p1] created 2024-01-01T00:00:00.000Z"

Section

Default Type Parameters

Write <T = string> and callers can skip the angle brackets entirely. TypeScript uses the default automatically, just like a default function argument.

2 examples

Example

Default type parameter T = string

A default lets callers omit the angle brackets when the default type is good enough, while still allowing explicit override when needed.

interface FormField<T = string> {
  value: T
  label: string
  required: boolean
}

// No need to write <string>  it's the default
const nameField: FormField = {
  value: "Alice",
  label: "Name",
  required: true,
}

// Override the default when needed
const ageField: FormField<number> = {
  value: 30,
  label: "Age",
  required: false,
}

const activeField: FormField<boolean> = {
  value: true,
  label: "Active",
  required: true,
}

Example

Default with constraint T extends X = DefaultX

Defaults and constraints can combine. The default must itself satisfy the constraint.

type Serializable = string | number | boolean | null

// T must be Serializable, defaults to string
function serialize<T extends Serializable = string>(value: T): string {
  return JSON.stringify(value)
}

serialize("hello")   // works, T = string (default)
serialize(42)        // works, T = number (inferred)
serialize(true)      // works, T = boolean (inferred)

// Defaults in type aliases work the same way
type Cache<K extends string = string, V = unknown> = Map<K, V>

const generic: Cache = new Map()                   // Map<string, unknown>
const specific: Cache<string, number> = new Map()  // Map<string, number>

Section

Multiple Type Parameters

Separate multiple parameters with commas: <A, B>. Each is independent TypeScript tracks them separately. Two parameters cover most real-world cases.

3 examples

Example

Result<T, E> typed success and failure

A Result type with two parameters gives you typed success values and typed errors in one shape, avoiding untyped try/catch blocks.

type Result<T, E = Error> =
  | { success: true;  value: T }
  | { success: false; error: E }

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) return { success: false, error: "Division by zero" }
  return { success: true, value: a / b }
}

const result = divide(10, 2)

if (result.success) {
  console.log(result.value)   // number
} else {
  console.log(result.error)   // string
}

Example

zip<A, B> pairing two arrays

Two type parameters let you pair elements from arrays of different types, keeping each element's type precise in the output tuple.

function zip<A, B>(arrA: A[], arrB: B[]): [A, B][] {
  const length = Math.min(arrA.length, arrB.length)
  return Array.from({ length }, (_, i) => [arrA[i], arrB[i]])
}

const names  = ["Alice", "Bob", "Carol"]
const scores = [95, 87, 92]

const pairs = zip(names, scores)
// [string, number][]  →  [["Alice", 95], ["Bob", 87], ["Carol", 92]]

const flags = zip([1, 2, 3], [true, false, true])
// [number, boolean][]

Example

fromEntries<K, V> typed Object.fromEntries

Two type parameters give precise key and value types to a dictionary builder, improving on the loose return type of the native Object.fromEntries.

function fromEntries<K extends string, V>(
  entries: ReadonlyArray<readonly [K, V]>
): Record<K, V> {
  return Object.fromEntries(entries) as Record<K, V>
}

const entries = [
  ["alice", 95],
  ["bob",   87],
] as const

const scores = fromEntries(entries)
// Record<"alice" | "bob", number>
// scores.alice   →  number
// scores.unknown // Error: key doesn't exist

Section

Built-in Utility Types

TypeScript ships with generic utility types in the standard library each one takes a type parameter and transforms it. Knowing these saves you from reimplementing common transformations.

4 examples

Example

Partial<T> and Required<T>

Partial<T> makes all properties optional useful for update payloads. Required<T> does the opposite, removing all optionals.

type User = {
  id: number
  name: string
  email?: string
  age?: number
}

type UserUpdate = Partial<User>
// { id?: number; name?: string; email?: string; age?: number }

type FullUser = Required<User>
// { id: number; name: string; email: string; age: number }

function updateUser(id: number, changes: Partial<User>): void {
  // Only the provided fields are updated
}

updateUser(1, { name: "Bob" })           // valid
updateUser(1, { name: "Bob", age: 31 }) // valid

Example

Pick<T, K> and Omit<T, K>

Pick keeps only the keys you list. Omit removes the keys you list. Both use generics internally: Pick<T, K extends keyof T>.

type User = {
  id: number
  name: string
  email: string
  passwordHash: string
  createdAt: Date
}

// Only expose safe fields to the frontend
type PublicUser = Omit<User, "passwordHash">
// { id: number; name: string; email: string; createdAt: Date }

// Just the fields needed for a summary card
type UserSummary = Pick<User, "id" | "name">
// { id: number; name: string }

// Combine for layered shapes
type EditableUser = Pick<Partial<User>, "name" | "email">
// { name?: string; email?: string }

Example

Record<K, V>

Record<K, V> creates an object type where all keys are type K and all values are type V. Better inference than { [key: string]: V }.

type Status = "active" | "inactive" | "pending"

const statusConfig: Record<Status, { label: string; color: string }> = {
  active:   { label: "Active",   color: "green" },
  inactive: { label: "Inactive", color: "gray" },
  pending:  { label: "Pending",  color: "yellow" },
  // TypeScript errors if you miss any key or add an unknown one
}

type CountryCode = "US" | "GB" | "DE"
const dialCodes: Record<CountryCode, string> = {
  US: "+1",
  GB: "+44",
  DE: "+49",
}

Example

ReturnType<F> and Parameters<F>

ReturnType<F> extracts the return type of a function. Parameters<F> extracts the parameter tuple. Both use infer internally.

function fetchUser(id: number): Promise<{ name: string; email: string }> {
  return Promise.resolve({ name: "Alice", email: "a@example.com" })
}

type FetchUserResult = ReturnType<typeof fetchUser>
// Promise<{ name: string; email: string }>

type FetchUserParams = Parameters<typeof fetchUser>
// [id: number]

// Preserve types when wrapping functions
function withLogging<F extends (...args: unknown[]) => unknown>(
  fn: F
): (...args: Parameters<F>) => ReturnType<F> {
  return (...args) => {
    console.log("calling", fn.name, args)
    return fn(...args) as ReturnType<F>
  }
}

Section

Real-World Patterns

Patterns that show up constantly in production TypeScript each one combines constraints, multiple type parameters, and defaults into a single reusable building block.

3 examples

Example

useFetch<T> typed data fetching hook

A generic fetch hook gives every API call its own typed result without duplicating the loading/error/data state logic.

import { useState, useEffect } from "react"

type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error }

function useFetch<T>(url: string): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({ status: "idle" })

  useEffect(() => {
    setState({ status: "loading" })
    fetch(url)
      .then((res) => res.json() as Promise<T>)
      .then((data) => setState({ status: "success", data }))
      .catch((error: Error) => setState({ status: "error", error }))
  }, [url])

  return state
}

type Post = { id: number; title: string; body: string }

function PostPage({ id }: { id: number }) {
  const result = useFetch<Post>(`/api/posts/${id}`)

  if (result.status === "loading") return <p>Loading...</p>
  if (result.status === "error")   return <p>{result.error.message}</p>
  if (result.status === "success") return <h1>{result.data.title}</h1>
  return null
}

Example

createStore<S> tiny typed state manager

Generic state managers keep the store shape and the action handlers in sync. No any required anywhere in the implementation.

type StoreListener<S> = (state: S) => void

function createStore<S>(initialState: S) {
  let state = initialState
  const listeners = new Set<StoreListener<S>>()

  function getState(): S { return state }

  function setState(updater: S | ((prev: S) => S)): void {
    state = typeof updater === "function"
      ? (updater as (prev: S) => S)(state)
      : updater
    listeners.forEach((fn) => fn(state))
  }

  function subscribe(listener: StoreListener<S>): () => void {
    listeners.add(listener)
    return () => listeners.delete(listener)
  }

  return { getState, setState, subscribe }
}

type CartState = { items: string[]; total: number }
const cartStore = createStore<CartState>({ items: [], total: 0 })

cartStore.subscribe((state) => console.log("cart updated", state))
cartStore.setState((prev) => ({
  items: [...prev.items, "Widget"],
  total: prev.total + 9.99,
}))

Example

parseWith<T> validated parsing with a schema

A generic parse helper decouples validation logic from types. The schema function acts as both a validator and a type guard.

type Parser<T> = (raw: unknown) => T
type Result<T, E> = { success: true; value: T } | { success: false; error: E }

function parseWith<T>(raw: unknown, parser: Parser<T>): Result<T, string> {
  try {
    return { success: true, value: parser(raw) }
  } catch (err) {
    return { success: false, error: String(err) }
  }
}

type User = { id: number; name: string }

function parseUser(raw: unknown): User {
  if (
    typeof raw !== "object" || raw === null ||
    typeof (raw as Record<string, unknown>).id !== "number" ||
    typeof (raw as Record<string, unknown>).name !== "string"
  ) {
    throw new Error("Invalid user shape")
  }
  return raw as User
}

const result = parseWith(await response.json(), parseUser)
if (result.success) {
  console.log(result.value.name)  // string  fully typed
} else {
  console.error(result.error)     // string
}

Reference

Quick reference when to use what

  • Generic function: same logic, different types wrap<T>, first<T>, mapArray<A, B>
  • Generic interface/type: reusable shape with a variable slot ApiResponse<T>, Result<T, E>
  • Generic class: data structure that works for any type Stack<T>, TypedEmitter<Events>
  • Constraint (extends): you need to access a property on T T extends { length: number }
  • keyof constraint: safe property access: K extends keyof T
  • Default type parameter: most callers use the same type <T = string>
  • Utility types: transform existing types without rewriting them Partial<T>, Pick<T, K>, Record<K, V>