TypeScript Generics and Utility Types: The Complete Guide

A deep dive into generics, built-in utility types, conditional types, mapped types, and real-world custom type utilities — everything you need to write truly type-safe TypeScript at scale.

20 min read

Table of Contents

  1. Introduction — Why TypeScript Generics Matter
  2. Generic Functions — Your First Generic
  3. Generic Interfaces and Types
  4. Generic Constraints (extends keyword)
  5. Built-in Utility Types
  6. Conditional Types and infer
  7. Mapped Types and Template Literal Types
  8. Building Custom Utility Types
  9. Generics in React Components
  10. Common Mistakes and How to Fix Them
  11. Conclusion

Introduction — Why TypeScript Generics Matter

If you have been writing TypeScript for more than a few months, you have probably reached the point where basic type annotations feel limiting. You write a function that wraps an API response, and you find yourself duplicating the same wrapper for every response shape. You create a form validation utility, and suddenly you are maintaining separate versions for each form model. You build a state management hook, and the return types collapse into any or require awkward type casts. This is the exact problem that generics solve, and understanding them deeply is what separates a TypeScript user from a TypeScript developer.

Generics allow you to write code that works with multiple types while preserving full type information through every step of the computation. They are not a niche advanced feature reserved for library authors. They are the foundation of how TypeScript's own standard library is typed, how every major framework defines its APIs, and how production codebases maintain type safety at scale. When you call Array.prototype.map, you are using generics. When you use Promise<T>, you are using generics. When you reach for Partial<T> or Pick<T, K>, you are using generics built on top of other generic primitives like mapped types and conditional types.

This guide is not a surface-level introduction. I will walk you through the full spectrum — from your first generic function to building complex custom utility types that rival what ships with TypeScript itself. Every example is drawn from real production code. By the end, you will be comfortable reading the type signatures of any open-source library and writing your own type-level utilities that make your codebase safer and more expressive. Let us start at the beginning.

Generic Functions — Your First Generic

The simplest way to understand generics is to look at a real problem. Imagine you are writing a function that returns the first element of an array. Without generics, your options are limited: you either type the return as any and lose all type safety, or you write separate overloads for every type you plan to use. Both approaches are fundamentally broken at scale. Generics give you a third option: let the caller's input determine the output type.

// Without generics: type information is lost
function firstElement(arr: any[]): any {
  return arr[0];
}
const num = firstElement([1, 2, 3]); // type: any (useless)
const str = firstElement(["a", "b"]); // type: any (useless)

// With generics: type flows through
function firstElement<T>(arr: T[]): T | undefined {
  return arr[0];
}
const num = firstElement([1, 2, 3]); // type: number | undefined
const str = firstElement(["a", "b"]); // type: string | undefined

The <T> syntax declares a type parameter. Think of it as a variable that lives in the type world rather than the value world. When you call firstElement([1, 2, 3]), TypeScript infers that T is number based on the argument you passed. The return type is then computed as number | undefined. No manual annotation needed, no type assertions, no information lost. This is called type argument inference, and it is one of the most powerful features in TypeScript's type system.

You can also use multiple type parameters when a function relates different types to each other. A common example is a function that transforms a value using a callback:

function transform<Input, Output>(
  value: Input,
  fn: (input: Input) => Output
): Output {
  return fn(value);
}

const length = transform("hello", (s) => s.length);
// type: number

const parsed = transform("42", (s) => parseInt(s, 10));
// type: number

const wrapped = transform(42, (n) => ({ value: n }));
// type: { value: number }

Notice that TypeScript infers both Input and Output from the arguments. The callback parameter s is correctly typed as string in the first call and string in the second. The return type follows from whatever the callback returns. This pattern is the backbone of how libraries like Lodash, Ramda, and RxJS type their transformation pipelines.

You can also explicitly provide type arguments when inference is not sufficient or when you want to be more specific:

// Explicit type arguments
const result = transform<string, number>("42", (s) => parseInt(s, 10));

// Useful when the compiler cannot infer correctly
function createPair<A, B>(a: A, b: B): [A, B] {
  return [a, b];
}
const pair = createPair<string, number>("age", 30);
// type: [string, number]
Convention: Single uppercase letters like T, U, K, V are traditional type parameter names. Use T for a general type, K for keys, V for values, and E for elements. For complex generics, descriptive names like Input, Output, or Schema improve readability significantly.

Generic Interfaces and Types

Generics are not limited to functions. You can parameterize interfaces, type aliases, and classes. This is how you build reusable data structures that remain type-safe regardless of the data they contain. If you have ever used Array<T>, Map<K, V>, or Promise<T>, you have already consumed generic interfaces. Now let us build our own.

A very common pattern in application development is wrapping API responses in a standard envelope. Without generics, you would need a separate interface for every response type. With generics, you write it once:

// Generic API response wrapper
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: string;
}

// Usage with different data shapes
interface User {
  id: number;
  name: string;
  email: string;
}

interface Product {
  id: number;
  title: string;
  price: number;
}

type UserResponse = ApiResponse<User>;
// { data: User; status: number; message: string; timestamp: string }

type ProductListResponse = ApiResponse<Product[]>;
// { data: Product[]; status: number; message: string; timestamp: string }

// Paginated response builds on the base
interface PaginatedResponse<T> extends ApiResponse<T[]> {
  page: number;
  pageSize: number;
  totalCount: number;
  totalPages: number;
}

type PaginatedUsers = PaginatedResponse<User>;
// Includes data: User[], page, pageSize, totalCount, totalPages, etc.

Generic type aliases work the same way and are often preferred for union types and utility constructions:

// Generic result type (like Rust's Result)
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

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

const result = divide(10, 2);
if (result.ok) {
  console.log(result.value); // type: number (narrowed!)
} else {
  console.log(result.error); // type: string (narrowed!)
}

// Generic linked list
type LinkedList<T> = {
  value: T;
  next: LinkedList<T> | null;
};

const list: LinkedList<number> = {
  value: 1,
  next: { value: 2, next: { value: 3, next: null } },
};

Notice the Result type uses a default type parameter: E = Error. This means if you write Result<number> without specifying the error type, it defaults to Error. Default type parameters work exactly like default function parameters and are invaluable for reducing verbosity in common cases while still allowing customization when needed.

Generic Constraints (extends keyword)

Unconstrained generics accept literally any type, which is sometimes too permissive. If your function needs to access a specific property on the generic type, TypeScript will rightfully complain — it cannot guarantee that property exists on every possible type. Generic constraints solve this by narrowing the set of types that a type parameter can accept.

// Problem: T could be anything, so .length is not safe
function getLength<T>(value: T): number {
  return value.length; // Error: Property 'length' does not exist on type 'T'
}

// Solution: constrain T to types that have a length property
function getLength<T extends { length: number }>(value: T): number {
  return value.length; // OK: T is guaranteed to have .length
}

getLength("hello");     // OK: string has .length
getLength([1, 2, 3]);   // OK: array has .length
getLength({ length: 5 }); // OK: object literal has .length
getLength(42);           // Error: number does not have .length

The extends keyword in a generic context means "must be assignable to." It is a constraint, not inheritance. T extends { length: number } means "T can be any type, as long as it has at least a length property of type number." The type can have additional properties — the constraint only sets a minimum contract.

One of the most powerful constraint patterns is the keyof constraint, which restricts a type parameter to be a valid key of another type. This is the foundation of how Pick, Omit, and many other utility types work internally:

// Type-safe property accessor
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Alice", age: 30, email: "alice@example.com" };

const name = getProperty(user, "name");  // type: string
const age = getProperty(user, "age");    // type: number
getProperty(user, "phone");              // Error: "phone" is not in keyof User

// Type-safe event emitter pattern
function on<
  Events extends Record<string, (...args: any[]) => void>,
  K extends keyof Events
>(events: Events, event: K, handler: Events[K]): void {
  // register handler...
}

interface AppEvents {
  login: (userId: string) => void;
  logout: () => void;
  purchase: (productId: string, amount: number) => void;
}

declare const emitter: AppEvents;
on(emitter, "login", (userId) => console.log(userId));    // OK
on(emitter, "purchase", (pid, amt) => console.log(pid));  // OK
on(emitter, "unknown", () => {});                          // Error

You can also use multiple constraints by intersecting them. If you need a type that is both iterable and has a length property, you can write T extends Iterable<unknown> & { length: number }. Constraints compose naturally, and the TypeScript compiler will enforce all of them simultaneously at every call site.

Common Pitfall: Do not over-constrain your generics. If a function only needs to read a .id property, constrain to { id: string } rather than requiring a specific interface like User. Broader constraints make your functions more reusable and composable.

Built-in Utility Types

TypeScript ships with a rich set of built-in utility types that transform existing types into new shapes. These are not magic — they are implemented using the same generics, mapped types, and conditional types that you can write yourself. Understanding what they do and how they work is essential for any TypeScript developer working on production code. I use most of these daily, and they eliminate entire categories of boilerplate type definitions.

Quick Reference Table

Utility Type Description Example Input Example Output
Partial<T> Makes all properties optional { a: string; b: number } { a?: string; b?: number }
Required<T> Makes all properties required { a?: string; b?: number } { a: string; b: number }
Readonly<T> Makes all properties readonly { a: string } { readonly a: string }
Pick<T, K> Selects a subset of properties Pick<User, "id" | "name"> { id: number; name: string }
Omit<T, K> Removes specified properties Omit<User, "password"> User without password field
Record<K, V> Creates object type with key type K and value type V Record<string, number> { [key: string]: number }
ReturnType<T> Extracts the return type of a function ReturnType<typeof fn> Whatever fn returns
Parameters<T> Extracts parameter types as a tuple Parameters<typeof fn> [arg1Type, arg2Type, ...]
Extract<T, U> Extracts types from T assignable to U Extract<"a" | "b" | 1, string> "a" | "b"
Exclude<T, U> Removes types from T assignable to U Exclude<"a" | "b" | 1, string> 1
NonNullable<T> Removes null and undefined string | null | undefined string
Awaited<T> Unwraps Promise types recursively Promise<Promise<string>> string

Partial and Required in Practice

Partial<T> is probably the most commonly used utility type. It makes every property in T optional, which is exactly what you need for update operations where the caller should only provide the fields they want to change. Required<T> is its inverse — it forces all properties to be present, which is useful for configuration objects where you want to merge user-provided options with defaults.

interface User {
  id: number;
  name: string;
  email: string;
  avatar: string;
  role: "admin" | "user";
}

// Update function: only include fields you want to change
function updateUser(id: number, updates: Partial<User>): Promise<User> {
  // updates could be { name: "Alice" } or { email: "new@example.com", role: "admin" }
  return db.users.update({ where: { id }, data: updates });
}

// Config with defaults: ensure all options are present after merging
interface AppConfig {
  apiUrl?: string;
  timeout?: number;
  retries?: number;
  debug?: boolean;
}

function createApp(userConfig: AppConfig): Required<AppConfig> {
  const defaults: Required<AppConfig> = {
    apiUrl: "https://api.example.com",
    timeout: 5000,
    retries: 3,
    debug: false,
  };
  return { ...defaults, ...userConfig };
}

Pick and Omit for Shaping DTOs

Pick and Omit are essential for creating data transfer objects (DTOs) that expose only the fields appropriate for a given context. Instead of defining separate interfaces for every API response shape, derive them from your base model:

interface User {
  id: number;
  name: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
  updatedAt: Date;
  internalNotes: string;
}

// Public API response: exclude sensitive fields
type PublicUser = Omit<User, "passwordHash" | "internalNotes">;

// User list item: only the fields needed for a list view
type UserListItem = Pick<User, "id" | "name" | "email">;

// Create input: no id, no timestamps (server generates these)
type CreateUserInput = Omit<User, "id" | "createdAt" | "updatedAt" | "passwordHash"> & {
  password: string; // raw password instead of hash
};

Record for Dynamic Object Types

Record<K, V> creates an object type where every key of type K maps to a value of type V. This is especially useful when you need to model lookup tables, dictionaries, or grouped data:

// Permission matrix
type Role = "admin" | "editor" | "viewer";
type Permission = "read" | "write" | "delete";

const permissions: Record<Role, Permission[]> = {
  admin: ["read", "write", "delete"],
  editor: ["read", "write"],
  viewer: ["read"],
};

// Grouped data
type StatusCounts = Record<"active" | "inactive" | "pending", number>;

const counts: StatusCounts = {
  active: 150,
  inactive: 23,
  pending: 7,
};

ReturnType and Parameters for Function Introspection

ReturnType and Parameters extract type information from function signatures. These are indispensable when working with third-party libraries where you need to match function signatures without importing internal types:

function fetchUsers(page: number, limit: number, filter?: string) {
  // ... returns Promise<User[]>
  return Promise.resolve([] as User[]);
}

type FetchUsersReturn = ReturnType<typeof fetchUsers>;
// type: Promise<User[]>

type FetchUsersParams = Parameters<typeof fetchUsers>;
// type: [page: number, limit: number, filter?: string]

// Use with Awaited to unwrap the Promise
type FetchUsersData = Awaited<ReturnType<typeof fetchUsers>>;
// type: User[]

// Wrap any async function with error handling
async function withErrorHandling<T extends (...args: any[]) => Promise<any>>(
  fn: T,
  ...args: Parameters<T>
): Promise<Result<Awaited<ReturnType<T>>>> {
  try {
    const result = await fn(...args);
    return { ok: true, value: result };
  } catch (error) {
    return { ok: false, error: error as Error };
  }
}

Extract, Exclude, and NonNullable for Union Manipulation

These three utility types operate on union types by filtering members in or out based on assignability. They are the building blocks for more complex conditional type patterns:

type Event =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string }
  | { type: "scroll"; offset: number };

// Extract only click events
type ClickEvent = Extract<Event, { type: "click" }>;
// { type: "click"; x: number; y: number }

// Everything except scroll events
type NonScrollEvent = Exclude<Event, { type: "scroll" }>;
// { type: "click"; ... } | { type: "keypress"; ... }

// Remove null/undefined from a union
type MaybeUser = User | null | undefined;
type DefiniteUser = NonNullable<MaybeUser>;
// type: User

Conditional Types and infer

Conditional types are where TypeScript's type system starts to feel like a programming language of its own. The syntax T extends U ? X : Y mirrors a ternary expression: if T is assignable to U, the type resolves to X; otherwise, it resolves to Y. When combined with the infer keyword, conditional types can deconstruct complex types and extract pieces from within them.

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

type A = IsString<string>;  // true
type B = IsString<number>;  // false
type C = IsString<"hello">; // true (string literal extends string)

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

type X = ElementOf<string[]>;    // string
type Y = ElementOf<number[]>;    // number
type Z = ElementOf<(string | number)[]>; // string | number
type W = ElementOf<string>;      // never (not an array)

// Extracting the resolved type of a Promise
type UnwrapPromise<T> = T extends Promise<infer R> ? R : T;

type P1 = UnwrapPromise<Promise<string>>;  // string
type P2 = UnwrapPromise<Promise<number>>;  // number
type P3 = UnwrapPromise<string>;            // string (not a Promise, pass through)

The infer keyword introduces a type variable that TypeScript will attempt to fill in by pattern matching. In T extends (infer E)[], TypeScript checks whether T matches the pattern "array of something." If it does, E is bound to whatever that "something" is. If it does not match, the conditional falls to the false branch. This is the same mechanism that powers ReturnType, Parameters, and Awaited under the hood.

Conditional types become especially powerful when they distribute over union types. When a conditional type is applied to a naked type parameter (one not wrapped in a tuple or other structure), TypeScript applies the condition to each member of the union individually and collects the results:

type ToArray<T> = T extends any ? T[] : never;

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

// This is why Exclude works:
// Exclude<T, U> = T extends U ? never : T
type Example = Exclude<"a" | "b" | "c", "a" | "c">;
// Step 1: "a" extends "a" | "c" ? never : "a"  => never
// Step 2: "b" extends "a" | "c" ? never : "b"  => "b"
// Step 3: "c" extends "a" | "c" ? never : "c"  => never
// Result: never | "b" | never => "b"

// Prevent distribution by wrapping in a tuple
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;

type NonDistributed = ToArrayNonDist<string | number>;
// (string | number)[]  (single array, not a union)

Understanding distribution is critical because it explains behavior that otherwise seems mysterious. Many "bugs" in custom utility types are actually distribution happening when you did not expect it, or not happening when you needed it. The tuple wrapping trick ([T] extends [U]) is the standard way to opt out of distribution when you need the union to be treated as a single unit.

Advanced infer Patterns

// Extract function argument types at specific positions
type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type SecondArg<T> = T extends (a: any, second: infer S, ...rest: any[]) => any ? S : never;

type F1 = FirstArg<(name: string, age: number) => void>; // string
type F2 = SecondArg<(name: string, age: number) => void>; // number

// Extract the type of a specific property
type PropType<T, K extends string> = T extends { [key in K]: infer V } ? V : never;

type NameType = PropType<{ name: string; age: number }, "name">; // string

// Recursive unwrapping of nested Promises
type DeepAwaited<T> = T extends Promise<infer R> ? DeepAwaited<R> : T;

type D1 = DeepAwaited<Promise<Promise<Promise<string>>>>; // string
Practical Tip: TypeScript 4.7+ supports infer with an extends constraint: T extends Promise<infer R extends string> ? R : never. This lets you constrain what the inferred type must satisfy, which is useful for filtering and narrowing in a single step.

Mapped Types and Template Literal Types

Mapped types let you create new object types by transforming every property of an existing type. They iterate over a union of keys (usually obtained via keyof) and produce a new property for each one. This is the mechanism behind Partial, Required, Readonly, Pick, and Record. Once you understand mapped types, those built-in utility types stop being black boxes and become patterns you can extend and customize.

// This is how Partial is actually implemented
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// This is how Readonly is implemented
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

// This is how Pick is implemented
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

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

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

type NullableUser = Nullable<User>;
// { name: string | null; age: number | null; email: string | null }

Mapped types support modifier remapping with + and - prefixes. The - prefix removes a modifier, and + adds one (though + is the default and rarely written explicitly). This is how Required works internally:

// Remove the optional modifier from all properties
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

// Remove readonly from all properties (make mutable)
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

interface FrozenConfig {
  readonly host: string;
  readonly port: number;
  readonly debug: boolean;
}

type MutableConfig = Mutable<FrozenConfig>;
// { host: string; port: number; debug: boolean } (all writable)

Key Remapping with as

TypeScript 4.1 introduced key remapping in mapped types using the as clause. This lets you transform property names during mapping, which is incredibly powerful for creating derived interfaces with predictable naming conventions:

// Create getter methods for every property
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface Person {
  name: string;
  age: number;
  location: string;
}

type PersonGetters = Getters<Person>;
// {
//   getName: () => string;
//   getAge: () => number;
//   getLocation: () => string;
// }

// Filter out properties by remapping to never
type OnlyStringProperties<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type StringProps = OnlyStringProperties<Person>;
// { name: string; location: string }  (age is excluded)

Template Literal Types

Template literal types bring string manipulation into the type system. They work like JavaScript template literals but at the type level, and they combine powerfully with mapped types and union distribution:

// Basic template literal types
type Greeting = `Hello, ${string}`;
// matches "Hello, Alice", "Hello, Bob", etc.

type EventName = `${"click" | "focus" | "blur"}Handler`;
// "clickHandler" | "focusHandler" | "blurHandler"

// CSS unit types
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
// "16px", "1.5rem", "100vh", etc.

// Intrinsic string manipulation types
type Upper = Uppercase<"hello">;       // "HELLO"
type Lower = Lowercase<"HELLO">;       // "hello"
type Cap = Capitalize<"hello">;        // "Hello"
type Uncap = Uncapitalize<"Hello">;    // "hello"

// Build event handler types from event names
type DOMEvents = "click" | "mousedown" | "mouseup" | "keydown" | "keyup";
type EventHandlers = {
  [E in DOMEvents as `on${Capitalize<E>}`]: (event: Event) => void;
};
// {
//   onClick: (event: Event) => void;
//   onMousedown: (event: Event) => void;
//   onMouseup: (event: Event) => void;
//   onKeydown: (event: Event) => void;
//   onKeyup: (event: Event) => void;
// }

Template literal types are distributive over unions, which means `${A | B}_${C | D}` produces all four combinations: "A_C" | "A_D" | "B_C" | "B_D". This combinatorial expansion is incredibly useful for generating exhaustive sets of valid string keys, CSS class names, API route patterns, and event handler names.

Building Custom Utility Types

Now that you understand the building blocks — generics, constraints, conditional types, mapped types, and template literal types — you can combine them to build custom utility types that solve real problems in your codebase. The following examples are all drawn from production TypeScript projects. They demonstrate patterns you will reach for again and again.

DeepPartial: Recursively Optional Properties

The built-in Partial only makes top-level properties optional. In real applications, you often need to make nested properties optional too — for example, when applying a partial configuration overlay or patching a deeply nested state object:

type DeepPartial<T> = T extends object
  ? T extends Array<infer E>
    ? Array<DeepPartial<E>>
    : { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

interface AppConfig {
  server: {
    host: string;
    port: number;
    ssl: {
      enabled: boolean;
      cert: string;
      key: string;
    };
  };
  database: {
    url: string;
    pool: {
      min: number;
      max: number;
    };
  };
}

// All nested properties become optional
type ConfigOverride = DeepPartial<AppConfig>;

const override: ConfigOverride = {
  server: {
    ssl: {
      enabled: true,
      // cert and key are optional here
    },
  },
  // database is entirely optional
};

DeepReadonly: Immutable Object Trees

Similarly, Readonly only freezes top-level properties. DeepReadonly recursively prevents mutation at every level, which is essential for Redux-style state management, configuration objects, and any context where immutability must be enforced by the type system:

type DeepReadonly<T> = T extends (infer E)[]
  ? ReadonlyArray<DeepReadonly<E>>
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T;

interface State {
  users: {
    list: { id: number; name: string }[];
    selectedId: number | null;
  };
  ui: {
    theme: "light" | "dark";
    sidebar: { collapsed: boolean };
  };
}

type ImmutableState = DeepReadonly<State>;

declare const state: ImmutableState;
state.users.list[0].name = "Bob";       // Error: readonly
state.ui.sidebar.collapsed = true;       // Error: readonly
state.users.list.push({ id: 3, name: "Charlie" }); // Error: readonly array

StrictOmit: Omit That Catches Typos

The built-in Omit has a subtle problem: it accepts any string as a key, even ones that do not exist on the type. This means Omit<User, "pasword"> (note the typo) compiles without error and silently does nothing. StrictOmit constrains the key parameter to actual keys of the type:

type StrictOmit<T, K extends keyof T> = Omit<T, K>;

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

type SafeUser = StrictOmit<User, "password">; // OK
type Broken = StrictOmit<User, "pasword">;    // Error: "pasword" is not in keyof User

PathKeys: Dot-Notation Object Paths

This utility type generates all valid dot-notation paths through a nested object. It is used in form libraries, translation systems (i18n), and any system that references nested properties by string path:

type PathKeys<T, Prefix extends string = ""> = T extends object
  ? {
      [K in keyof T & string]: T[K] extends object
        ? PathKeys<T[K], `${Prefix}${K}.`> | `${Prefix}${K}`
        : `${Prefix}${K}`;
    }[keyof T & string]
  : never;

interface FormData {
  user: {
    name: string;
    address: {
      street: string;
      city: string;
    };
  };
  settings: {
    theme: string;
  };
}

type FormPaths = PathKeys<FormData>;
// "user" | "user.name" | "user.address" | "user.address.street"
// | "user.address.city" | "settings" | "settings.theme"

// Type-safe path accessor
function getValue<T, P extends PathKeys<T>>(obj: T, path: P): unknown {
  return path.split(".").reduce((acc: any, key) => acc?.[key], obj);
}

Branded Types: Nominal Typing in a Structural System

TypeScript uses structural typing, which means two types with the same shape are interchangeable. This is usually a feature, but sometimes you need to distinguish between types that have the same structure but different semantics — like UserId vs ProductId, both of which are string at runtime:

// Brand tag (phantom type)
declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [__brand]: B };

type UserId = Brand<string, "UserId">;
type ProductId = Brand<string, "ProductId">;
type OrderId = Brand<string, "OrderId">;

// Constructor functions
function UserId(id: string): UserId { return id as UserId; }
function ProductId(id: string): ProductId { return id as ProductId; }

function getUser(id: UserId): Promise<User> { /* ... */ }
function getProduct(id: ProductId): Promise<Product> { /* ... */ }

const userId = UserId("usr_123");
const productId = ProductId("prod_456");

getUser(userId);      // OK
getUser(productId);   // Error: ProductId is not assignable to UserId
getUser("raw_string"); // Error: string is not assignable to UserId

Branded types add zero runtime overhead — the brand exists only in the type system. They are invaluable for preventing the class of bugs where identifiers get mixed up across entity boundaries, which is a surprisingly common source of production errors in large codebases.

Generics in React Components

React and generics are a natural pairing. Many components need to be reusable across different data shapes while maintaining full type safety for callbacks, render props, and state. TypeScript supports generic components through both function syntax and arrow function syntax, though each has its quirks.

// Generic list component
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string | number;
  onSelect?: (item: T) => void;
  emptyMessage?: string;
}

function List<T>({ items, renderItem, keyExtractor, onSelect, emptyMessage }: ListProps<T>) {
  if (items.length === 0) {
    return <p>{emptyMessage ?? "No items found."}</p>;
  }
  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)} onClick={() => onSelect?.(item)}>
          {renderItem(item, index)}
        </li>
      ))}
    </ul>
  );
}

// Usage: T is inferred as User from the items prop
<List
  items={users}
  renderItem={(user) => <span>{user.name} ({user.email})</span>}
  keyExtractor={(user) => user.id}
  onSelect={(user) => navigate(`/users/${user.id}`)}
/>

The key insight is that TypeScript infers the type parameter T from whichever prop provides enough information. In the example above, items={users} tells TypeScript that T is User, and then renderItem, keyExtractor, and onSelect all receive correctly typed parameters with no manual annotations needed.

Generic Select/Dropdown Component

A classic use case is a type-safe select component that works with any option shape:

interface SelectProps<T> {
  options: T[];
  value: T | null;
  onChange: (value: T) => void;
  getLabel: (option: T) => string;
  getValue: (option: T) => string | number;
  placeholder?: string;
}

function Select<T>({ options, value, onChange, getLabel, getValue, placeholder }: SelectProps<T>) {
  return (
    <select
      value={value ? String(getValue(value)) : ""}
      onChange={(e) => {
        const selected = options.find(
          (opt) => String(getValue(opt)) === e.target.value
        );
        if (selected) onChange(selected);
      }}
    >
      {placeholder && <option value="">{placeholder}</option>}
      {options.map((opt) => (
        <option key={getValue(opt)} value={getValue(opt)}>
          {getLabel(opt)}
        </option>
      ))}
    </select>
  );
}

// Usage with Country type
interface Country { code: string; name: string; population: number; }

<Select<Country>
  options={countries}
  value={selectedCountry}
  onChange={setSelectedCountry}
  getLabel={(c) => c.name}
  getValue={(c) => c.code}
  placeholder="Select a country..."
/>

Generic Custom Hooks

Generics in hooks follow the same patterns as generic functions. A common production pattern is a type-safe fetch hook:

interface UseFetchResult<T> {
  data: T | null;
  error: Error | null;
  loading: boolean;
  refetch: () => void;
}

function useFetch<T>(url: string, options?: RequestInit): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [loading, setLoading] = useState(true);

  const fetchData = useCallback(async () => {
    setLoading(true);
    try {
      const response = await fetch(url, options);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const json: T = await response.json();
      setData(json);
      setError(null);
    } catch (err) {
      setError(err as Error);
    } finally {
      setLoading(false);
    }
  }, [url]);

  useEffect(() => { fetchData(); }, [fetchData]);

  return { data, error, loading, refetch: fetchData };
}

// Usage: result.data is typed as User[] | null
const { data: users, loading, error } = useFetch<User[]>("/api/users");
Arrow Function Generics in TSX: In .tsx files, the syntax <T>(args) => body is ambiguous because <T> looks like a JSX tag. Use <T,>(args) => body (trailing comma) or <T extends unknown>(args) => body to disambiguate. Function declarations (function foo<T>) do not have this problem.

Common Mistakes and How to Fix Them

Even experienced TypeScript developers fall into these traps. After years of code reviews and debugging type-level issues, these are the mistakes I see most frequently, along with the correct patterns to replace them.

Mistake 1: Using Generics When You Do Not Need Them

Not every function benefits from generics. If the type parameter appears only once and does not establish a relationship between inputs and outputs, it is adding complexity without value:

// Bad: generic adds nothing here
function logValue<T>(value: T): void {
  console.log(value);
}

// Good: just use unknown or the specific type you need
function logValue(value: unknown): void {
  console.log(value);
}

// Bad: T is only used in one position
function wrapInArray<T>(value: T): unknown[] {
  return [value];
}

// Good: T connects input to output, this IS useful
function wrapInArray<T>(value: T): T[] {
  return [value];
}

The rule of thumb: a type parameter should appear at least twice in the function signature. If it appears in the input and the output, or in two different parameters, it is creating a valuable type relationship. If it appears only once, you probably do not need it.

Mistake 2: Using any Instead of unknown for Truly Unknown Types

// Bad: any silently disables type checking
function parseJSON(text: string): any {
  return JSON.parse(text);
}
const data = parseJSON('{"name": "Alice"}');
data.nonExistent.nested.call(); // No error! (runtime crash)

// Good: unknown forces the caller to narrow the type
function parseJSON(text: string): unknown {
  return JSON.parse(text);
}
const data = parseJSON('{"name": "Alice"}');
// data.name; // Error: Object is of type 'unknown'

// Caller must validate the shape
if (typeof data === "object" && data !== null && "name" in data) {
  console.log((data as { name: string }).name); // OK after narrowing
}

// Even better: use a generic with a runtime validator
function parseJSON<T>(text: string, validator: (data: unknown) => data is T): T {
  const parsed = JSON.parse(text);
  if (!validator(parsed)) throw new Error("Validation failed");
  return parsed;
}

Mistake 3: Forgetting That Conditional Types Distribute Over Unions

// Surprising: you expect string[] but get string[] | number[]
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// string[] | number[]  (distributed!)

// If you want (string | number)[], prevent distribution:
type ToArrayFixed<T> = [T] extends [any] ? T[] : never;
type FixedResult = ToArrayFixed<string | number>;
// (string | number)[]

Mistake 4: Over-Constraining Generic Parameters

// Bad: requires User specifically, not reusable
function getId<T extends User>(entity: T): number {
  return entity.id;
}

// Good: constrain to the minimum required shape
function getId<T extends { id: number }>(entity: T): number {
  return entity.id;
}

// Now works with User, Product, Order, or any type with id: number
getId({ id: 1, name: "Alice" });           // OK
getId({ id: 42, title: "Widget", price: 9.99 }); // OK

Mistake 5: Mutating Generic-Typed State

// Bad: mutates the input array
function addItem<T>(list: T[], item: T): T[] {
  list.push(item); // Mutates the original!
  return list;
}

// Good: return a new array
function addItem<T>(list: readonly T[], item: T): T[] {
  return [...list, item];
}

// Using readonly in the parameter signals immutability intent
// and prevents accidental mutation at the type level

Mistake 6: Not Using const Type Parameters (TypeScript 5.0+)

// Without const: TypeScript widens the inferred type
function createRoutes<T extends Record<string, string>>(routes: T): T {
  return routes;
}
const routes = createRoutes({ home: "/", about: "/about" });
// type: { home: string; about: string } (widened!)

// With const: TypeScript preserves literal types
function createRoutes<const T extends Record<string, string>>(routes: T): T {
  return routes;
}
const routes = createRoutes({ home: "/", about: "/about" });
// type: { readonly home: "/"; readonly about: "/about" } (exact literals!)

The const modifier on type parameters (introduced in TypeScript 5.0) tells the compiler to infer the narrowest possible type for the argument, preserving literal types and readonly modifiers. This is essential for type-safe routing, configuration objects, and any API where exact string values matter.

Performance Warning: Deeply recursive conditional types and mapped types can significantly slow down the TypeScript compiler. If your IDE starts lagging, check for recursive utility types that process large unions or deeply nested object types. TypeScript has a recursion depth limit of around 50 levels, and hitting it produces the cryptic "Type instantiation is excessively deep and possibly infinite" error. Simplify your types or add explicit intermediate type annotations to break the recursion chain.

Conclusion

TypeScript's generic system is one of the most expressive type systems available in a mainstream programming language. It gives you the tools to write code that is simultaneously flexible and safe — functions that work with any data shape while catching type errors at compile time rather than in production. The journey from basic <T> type parameters to building custom utility types like DeepPartial, PathKeys, and branded types is a progression that every serious TypeScript developer should make.

The key takeaways from this guide are these. First, generics are about establishing type relationships — between function inputs and outputs, between interface properties and their consumers, between base types and their derived shapes. If a type parameter does not create a meaningful relationship, remove it. Second, the built-in utility types are not magic. They are built with the same mapped types, conditional types, and infer patterns available to you. Understanding their implementation makes you a better type-level programmer. Third, start simple. Use Partial, Pick, Omit, and Record in your daily work. When those are not enough, reach for conditional types. When those are not enough, build custom utility types. The progression should be driven by real problems, not type-level gymnastics for their own sake.

The TypeScript ecosystem in 2026 is rich with libraries that push generics to their limits — Zod for runtime schema validation with inferred types, tRPC for end-to-end type-safe APIs, Drizzle ORM for type-safe database queries, and many more. Being fluent in generics is what lets you read, understand, and contribute to these libraries rather than treating them as opaque dependencies. It is also what lets you build your own application-level type utilities that make your codebase safer and more maintainable over time.

If you have read this far, you have the foundation to write TypeScript at a level that most developers never reach. The next step is practice: take a real codebase, identify places where types are too loose (look for any, type assertions, and duplicated interfaces), and apply the patterns from this guide to tighten them up. The compiler errors you get along the way are not obstacles — they are the type system doing its job, catching bugs before they ever reach your users.

Convert Zod schemas to TypeScript types instantly with our free Zod Converter tool.

Zod to TypeScript Converter →