Many people use TypeScript without ever writing a generic. The code compiles, works, and seems sufficient. But when you need a function that works with any data type while maintaining type safety, generics stop being theory and become necessity.

Basic generics: when and why

A generic is a type parameter. Just like a function accepts value parameters, a generic accepts type parameters. Its reason to exist: write once, type for any type.

// Without generic: need a function for each type
function getFirstString(arr: string[]): string | undefined { return arr[0] }
function getFirstNumber(arr: number[]): number | undefined { return arr[0] }

// With generic: one function for all
function getFirst<T>(arr: T[]): T | undefined {
  return arr[0]
}

const a = getFirst(['a', 'b', 'c'])   // type: string | undefined
const b = getFirst([1, 2, 3])          // type: number | undefined

TypeScript infers the generic type automatically. When you pass a string array, T becomes string. No need to declare it.

Constraints: limiting what the generic accepts

Sometimes the generic needs a guarantee. "Works with any type, but that type must have this property." Constraints solve this with extends:

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

const user = { name: 'Marc', age: 30 }
getProperty(user, 'name')  // ok: returns string
getProperty(user, 'email') // error: 'email' doesn't exist on user

keyof T returns a union of T's property names. K extends keyof T ensures the passed key exists on the object. The error happens at compile time, not runtime.

Conditional types: types that decide

Conditional types use the syntax T extends U ? X : Y. The resulting type depends on a condition. Think ternary, but for types.

type IsString<T> = T extends string ? true : false

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

The real use case: deriving types based on inputs. An API that returns different types depending on the parameter.

type ApiResponse<T extends 'user' | 'product'> =
  T extends 'user'    ? { id: string; name: string; email: string }
  : T extends 'product' ? { id: string; title: string; price: number }
  : never

type UserResponse = ApiResponse<'user'>    // { id: string; name: string; email: string }
type ProductResponse = ApiResponse<'product'> // { id: string; title: string; price: number }

Mapped types: transforming types in bulk

Mapped types iterate over a type's properties and produce a new type. The syntax is { [K in Keys]: NewType }.

// Makes all properties optional
type MyPartial<T> = { [K in keyof T]?: T[K] }

// Makes all properties readonly
type MyReadonly<T> = { readonly [K in keyof T]: T[K] }

// Makes specific properties required
type WithRequired<T, K extends keyof T> =
  T & Required<Pick<T, K>>

type User = { name?: string; email?: string; id?: string }
type UserWithId = WithRequired<User, 'id'>
// result: { name?: string; email?: string; id: string }

TypeScript utility types you already use (or should)

TypeScript ships with built-in utility types. Knowing the most common ones avoids reinventing the wheel:

type User = { id: string; name: string; email: string; role: 'admin' | 'user' }

// Pick: selects properties
type UserBasic = Pick<User, 'id' | 'name'>

// Omit: removes properties
type CreateUser = Omit<User, 'id'>

// Record: creates type with typed keys and values
type Roles = Record<string, { permissions: string[] }>

// Extract: extracts types from a union
type AdminRole = Extract<User['role'], 'admin'>

// Exclude: removes types from a union
type NonAdmin = Exclude<User['role'], 'admin'>  // 'user'

Real pattern: typing a function that accepts callbacks

A common pattern combining generics with constraints: a function that accepts callbacks with types related to the input.

function mapWithFallback<TInput, TOutput>(
  items: TInput[],
  transform: (item: TInput) => TOutput,
  fallback: TOutput
): TOutput[] {
  return items.map(item => {
    try {
      return transform(item)
    } catch {
      return fallback
    }
  })
}

// TypeScript infers TInput = string, TOutput = number
const result = mapWithFallback(
  ['1', '2', 'abc'],
  s => parseInt(s),
  0
)  // type: number[]

When to stop overcomplicating

Generics and conditional types are powerful, but they have a readability cost. If you're writing a type nobody on the team can read in 10 seconds, simplify. Type safety matters, but maintainability matters more.

Use generics when you need type flexibility. Use conditional types when you have genuine type logic. Use mapped types when you need to transform structures. And when none of that applies, a documented, localized any is better than a genius type nobody understands.