DEV Community

HK Lee
HK Lee

Posted on • Originally published at pockit.tools

TypeScript Generics Demystified: From Confusion to Mastery (With Real-World Patterns)

Generics are the single most powerful feature in TypeScript's type system — and the most misunderstood. If you've ever stared at a type signature like <T extends Record<string, unknown>, K extends keyof T> and felt your brain short-circuit, you're not alone.

Here's the thing: generics aren't complicated. They're just functions for types. Once that clicks, everything else falls into place. This guide will take you from "I sort of understand generics" to "I can write type-safe utility libraries" in one sitting.

What Generics Actually Are (The 60-Second Mental Model)

A generic is a type variable — a placeholder for a type that gets filled in later. Just like a function parameter is a placeholder for a value.

Regular function:

function identity(value: string): string {
  return value;
}
Enter fullscreen mode Exit fullscreen mode

This only works for strings. What if we want it to work for any type?

Without generics — you lose type information:

function identity(value: any): any {
  return value;
}

const result = identity("hello"); // result is 'any' — useless
Enter fullscreen mode Exit fullscreen mode

With generics — the type flows through:

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

const result = identity("hello"); // result is 'string' ✅
const num = identity(42);         // num is 'number' ✅
Enter fullscreen mode Exit fullscreen mode

T is the generic type parameter. When you call identity("hello"), TypeScript infers that T = string and carries that information through the return type. You write the function once, and it works correctly for any type while preserving type safety.

This is the entire concept. Everything else in this guide builds on this one idea.

Beyond the Basics: Generic Constraints

Unconstrained generics accept anything. That's not always what you want.

The Problem: Too Permissive

function getLength<T>(value: T): number {
  return value.length; // ❌ Error: Property 'length' does not exist on type 'T'
}
Enter fullscreen mode Exit fullscreen mode

TypeScript doesn't know that T has a length property. It could be a number, a boolean, anything.

The Fix: extends Keyword

function getLength<T extends { length: number }>(value: T): number {
  return value.length; // ✅ Works — T is guaranteed to have 'length'
}

getLength("hello");     // ✅ string has length
getLength([1, 2, 3]);   // ✅ array has length
getLength(42);          // ❌ Error: number doesn't have length
Enter fullscreen mode Exit fullscreen mode

The extends keyword adds a constraint — it tells TypeScript "T must be a type that has at least these properties." Think of it as a minimum interface.

Real-World Pattern: API Response Wrapper

interface ApiResponse<T> {
  data: T;
  status: number;
  timestamp: string;
}

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

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

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

type ProductResponse = ApiResponse<Product>;
// { data: Product; status: number; timestamp: string; }
Enter fullscreen mode Exit fullscreen mode

One interface, infinitely reusable. The data field is type-safe for each use case.

Multiple Type Parameters

Generics can have multiple parameters, just like functions can have multiple arguments:

function pair<A, B>(first: A, second: B): [A, B] {
  return [first, second];
}

const result = pair("hello", 42); // [string, number]
Enter fullscreen mode Exit fullscreen mode

Real-World Pattern: Type-Safe Event Emitter

type EventMap = {
  userLogin: { userId: string; timestamp: Date };
  pageView: { url: string; referrer: string };
  purchase: { productId: string; amount: number };
};

class TypedEventEmitter<Events extends Record<string, any>> {
  private handlers: Partial<{
    [K in keyof Events]: Array<(payload: Events[K]) => void>;
  }> = {};

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

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

const emitter = new TypedEventEmitter<EventMap>();

emitter.on("userLogin", (payload) => {
  console.log(payload.userId);     // ✅ Autocomplete works
  console.log(payload.timestamp);  // ✅ Type-safe
});

emitter.on("purchase", (payload) => {
  console.log(payload.amount);     // ✅ number
});

emitter.emit("pageView", {
  url: "/home",
  referrer: "google.com",
}); // ✅ Payload shape is validated
Enter fullscreen mode Exit fullscreen mode

This pattern eliminates an entire class of runtime bugs. The event name and payload shape are linked at the type level. If you rename an event or change its payload, TypeScript catches every broken handler at compile time.

Generic Utility Types: TypeScript's Built-in Power Tools

TypeScript ships with several generic utility types that every developer should know. These are built using the same generic patterns you're learning.

Partial<T> — Make All Properties Optional

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

function updateUser(id: string, updates: Partial<User>): void {
  // updates can have any combination of User properties
}

updateUser("123", { name: "Alice" });        // ✅
updateUser("123", { email: "a@b.com" });     // ✅
updateUser("123", { invalid: true });        // ❌ Error
Enter fullscreen mode Exit fullscreen mode

Pick<T, K> — Select Specific Properties

type UserPreview = Pick<User, "name" | "email">;
// { name: string; email: string; }
Enter fullscreen mode Exit fullscreen mode

Omit<T, K> — Remove Specific Properties

type CreateUserDto = Omit<User, "id" | "createdAt">;
// Everything from User except id and createdAt
Enter fullscreen mode Exit fullscreen mode

Record<K, V> — Create Object Types

type StatusMap = Record<"active" | "inactive" | "banned", User[]>;
// { active: User[]; inactive: User[]; banned: User[]; }
Enter fullscreen mode Exit fullscreen mode

ReturnType<T> — Extract Function Return Type

function createUser() {
  return { id: "1", name: "Alice", role: "admin" as const };
}

type NewUser = ReturnType<typeof createUser>;
// { id: string; name: string; role: "admin" }
Enter fullscreen mode Exit fullscreen mode

How Partial Works Under the Hood

Here's the actual implementation:

type Partial<T> = {
  [P in keyof T]?: T[P];
};
Enter fullscreen mode Exit fullscreen mode

This is a mapped type. It iterates over every key P in T, makes each one optional (?), and preserves the original value type (T[P]). Understanding this unlocks the ability to create your own utility types.

Conditional Types: Logic at the Type Level

Conditional types let you write if/else logic in the type system:

type IsString<T> = T extends string ? "yes" : "no";

type A = IsString<string>;  // "yes"
type B = IsString<number>;  // "no"
Enter fullscreen mode Exit fullscreen mode

This might look academic, but conditional types solve real problems.

Real-World Pattern: API Response Unwrapper

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type A = UnwrapPromise<Promise<string>>;  // string
type B = UnwrapPromise<Promise<number>>;  // number
type C = UnwrapPromise<string>;           // string (not a Promise, returned as-is)
Enter fullscreen mode Exit fullscreen mode

The infer keyword captures a type from within a pattern. Here, it extracts the inner type from a Promise<>.

Real-World Pattern: Deep Property Access

type NestedValue<T, Path extends string> =
  Path extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? NestedValue<T[Key], Rest>
      : never
    : Path extends keyof T
      ? T[Path]
      : never;

interface Config {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
  cache: {
    ttl: number;
  };
}

type DbHost = NestedValue<Config, "database.host">;
// string

type DbUser = NestedValue<Config, "database.credentials.username">;
// string

type CacheTtl = NestedValue<Config, "cache.ttl">;
// number
Enter fullscreen mode Exit fullscreen mode

This is the kind of type that powers libraries like Lodash's _.get() or tRPC's path-based API. The type system recursively walks the object structure to produce the correct return type.

Mapped Types: Transform Types Programmatically

Mapped types let you create new types by transforming existing ones:

// Make all properties readonly
type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

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

// Make all properties required (remove optional)
type Required<T> = {
  [P in keyof T]-?: T[P];
};
Enter fullscreen mode Exit fullscreen mode

Real-World Pattern: Form State Types

interface UserForm {
  name: string;
  email: string;
  bio: string;
}

// Generate error state for each field
type FormErrors<T> = {
  [K in keyof T]?: string;
};

// Generate touched state for each field
type FormTouched<T> = {
  [K in keyof T]: boolean;
};

// Usage
const errors: FormErrors<UserForm> = {
  email: "Invalid email format",
};

const touched: FormTouched<UserForm> = {
  name: true,
  email: true,
  bio: false,
};
Enter fullscreen mode Exit fullscreen mode

One interface for your form data, and you derive the error state and touched state automatically. Add a new field to UserForm, and the type system enforces that FormTouched includes it too.

Template Literal Types: String Manipulation at the Type Level

TypeScript can manipulate strings in the type system:

type EventName<T extends string> = `on${Capitalize<T>}`;

type ClickEvent = EventName<"click">;     // "onClick"
type FocusEvent = EventName<"focus">;     // "onFocus"
type SubmitEvent = EventName<"submit">;   // "onSubmit"
Enter fullscreen mode Exit fullscreen mode

Real-World Pattern: CSS Utility Type Generator

type Spacing = 0 | 1 | 2 | 4 | 8 | 16;
type Direction = "t" | "r" | "b" | "l" | "x" | "y";
type SpacingClass = `${"m" | "p"}${Direction}-${Spacing}`;

// SpacingClass = "mt-0" | "mt-1" | "mt-2" | ... | "py-16"
// All 144 utility classes, type-checked
Enter fullscreen mode Exit fullscreen mode

Real-World Pattern: Type-Safe Route Builder

type ExtractParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<`/${Rest}`>
    : T extends `${string}:${infer Param}`
      ? Param
      : never;

type Params = ExtractParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"

function buildUrl<T extends string>(
  template: T,
  params: Record<ExtractParams<T>, string>
): string {
  return Object.entries(params).reduce(
    (url, [key, value]) => url.replace(`:${key}`, value as string),
    template as string
  );
}

buildUrl("/users/:userId/posts/:postId", {
  userId: "123",
  postId: "456",
}); // ✅

buildUrl("/users/:userId/posts/:postId", {
  userId: "123",
  // ❌ Error: missing 'postId'
});
Enter fullscreen mode Exit fullscreen mode

The type system parses the URL template string and forces you to provide every parameter. Remove a parameter from the template, and TypeScript immediately tells you where you're still passing it unnecessarily.

The satisfies Operator: Type-Checking Without Widening

Introduced in TypeScript 4.9, satisfies validates that a value conforms to a type without changing its inferred type:

type Color = "red" | "green" | "blue";
type ColorMap = Record<Color, string | number[]>;

// Without satisfies — type is widened
const colors1: ColorMap = {
  red: "#ff0000",
  green: [0, 255, 0],
  blue: "#0000ff",
};
colors1.red.toUpperCase(); // ❌ Error: might be number[]

// With satisfies — literal types preserved
const colors2 = {
  red: "#ff0000",
  green: [0, 255, 0],
  blue: "#0000ff",
} satisfies ColorMap;
colors2.red.toUpperCase();          // ✅ TypeScript knows it's a string
colors2.green.map((c) => c * 2);   // ✅ TypeScript knows it's number[]
Enter fullscreen mode Exit fullscreen mode

satisfies gives you the best of both worlds: constraint validation AND precise type inference.

Generic Best Practices

1. Name Type Parameters Descriptively

Single-letter type parameters are fine for simple generics, but complex ones deserve names:

// ❌ Hard to read
function merge<A, B, C>(source: A, override: B, defaults: C): A & B & C;

// ✅ Much clearer
function merge<
  TSource,
  TOverride,
  TDefaults,
>(source: TSource, override: TOverride, defaults: TDefaults): TSource & TOverride & TDefaults;
Enter fullscreen mode Exit fullscreen mode

2. Don't Use Generics When You Don't Need Them

// ❌ Unnecessary generic — T is never used meaningfully
function greet<T extends string>(name: T): string {
  return `Hello, ${name}!`;
}

// ✅ Just use the type directly
function greet(name: string): string {
  return `Hello, ${name}!`;
}
Enter fullscreen mode Exit fullscreen mode

If the type parameter only appears once in the function signature, you probably don't need a generic.

3. Use Defaults for Generic Parameters

interface ApiResponse<T = unknown> {
  data: T;
  status: number;
}

// Can use without specifying T
const response: ApiResponse = { data: null, status: 200 };

// Or specify it explicitly
const userResponse: ApiResponse<User> = { data: user, status: 200 };
Enter fullscreen mode Exit fullscreen mode

4. Constrain, Don't Assert

// ❌ Using 'as' to force types
function getProperty(obj: any, key: string) {
  return obj[key] as any;
}

// ✅ Using generics with constraints
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");   // ✅ Returns string
getProperty(user, "age");    // ✅ Returns number
getProperty(user, "email");  // ❌ Error: "email" not in keyof user
Enter fullscreen mode Exit fullscreen mode

Common Mistakes and How to Fix Them

Mistake 1: Over-Engineering with Too Many Generics

// ❌ This is unreadable and unnecessary
type OverEngineered<T extends object, K extends keyof T, V extends T[K]> = {
  key: K;
  value: V;
  original: T;
};

// ✅ Simpler version that does the same thing
type PropertyEntry<T extends object> = {
  [K in keyof T]: { key: K; value: T[K]; original: T };
}[keyof T];
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Forgetting extends on Conditional Types with Unions

Conditional types distribute over unions:

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

type Result = ToArray<string | number>;
// string[] | number[]  (NOT (string | number)[])
Enter fullscreen mode Exit fullscreen mode

If you want to prevent distribution, wrap both sides in a tuple:

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

type Result = ToArrayNonDist<string | number>;
// (string | number)[]
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Not Using const Type Parameters

TypeScript 5.0+ supports const type parameters for preserving literal types:

// Without const — literals are widened
function createConfig<T>(config: T) {
  return config;
}
const c1 = createConfig({ mode: "production" });
// { mode: string } — widened to string

// With const — literals are preserved
function createConfig<const T>(config: T) {
  return config;
}
const c2 = createConfig({ mode: "production" });
// { mode: "production" } — literal type preserved ✅
Enter fullscreen mode Exit fullscreen mode

Putting It All Together: A Type-Safe Query Builder

Let's build something real — a type-safe query builder that combines multiple generic patterns:

interface Schema {
  users: {
    id: string;
    name: string;
    email: string;
    age: number;
    role: "admin" | "user";
  };
  posts: {
    id: string;
    title: string;
    content: string;
    authorId: string;
    published: boolean;
  };
  comments: {
    id: string;
    text: string;
    postId: string;
    userId: string;
  };
}

type WhereClause<T> = {
  [K in keyof T]?: T[K] | { gt?: T[K]; lt?: T[K]; eq?: T[K] };
};

type OrderBy<T> = {
  field: keyof T;
  direction: "asc" | "desc";
};

class QueryBuilder<
  TSchema extends Record<string, Record<string, any>>,
  TTable extends keyof TSchema = keyof TSchema,
> {
  private table: TTable | null = null;
  private whereClause: WhereClause<TSchema[TTable]> = {};
  private selectedFields: (keyof TSchema[TTable])[] = [];
  private orderByClause: OrderBy<TSchema[TTable]> | null = null;

  from<T extends keyof TSchema>(table: T): QueryBuilder<TSchema, T> {
    const qb = new QueryBuilder<TSchema, T>();
    (qb as any).table = table;
    return qb;
  }

  select<K extends keyof TSchema[TTable]>(
    ...fields: K[]
  ): QueryBuilder<TSchema, TTable> {
    this.selectedFields = fields;
    return this;
  }

  where(clause: WhereClause<TSchema[TTable]>): QueryBuilder<TSchema, TTable> {
    this.whereClause = clause;
    return this;
  }

  orderBy(
    field: keyof TSchema[TTable],
    direction: "asc" | "desc" = "asc"
  ): QueryBuilder<TSchema, TTable> {
    this.orderByClause = { field, direction };
    return this;
  }

  build(): string {
    const fields =
      this.selectedFields.length > 0
        ? this.selectedFields.join(", ")
        : "*";
    return `SELECT ${fields} FROM ${String(this.table)}`;
  }
}

const db = new QueryBuilder<Schema>();

// Full type safety — autocomplete works everywhere
db.from("users")
  .select("name", "email")
  .where({ role: "admin", age: { gt: 18 } })
  .orderBy("name", "desc");

db.from("posts")
  .select("title", "published")
  .where({ published: true });

// ❌ These all produce compile-time errors:
// db.from("users").select("title");        // 'title' doesn't exist on users
// db.from("posts").where({ role: "admin" }); // 'role' doesn't exist on posts
// db.from("invalid");                        // 'invalid' not in schema
Enter fullscreen mode Exit fullscreen mode

This is the power of generics. One class handles every table in your schema. The type system ensures you can only select columns that exist, filter on valid fields with the correct types, and order by real columns. All validated at compile time, zero runtime overhead.

When to Reach for Generics

Generics shine when:

  • You're writing reusable utilities — functions, classes, or types used across multiple data shapes
  • Type relationships matter — the output type depends on the input type
  • You're building APIs — public interfaces where consumers pass their own types
  • You want to eliminate any — generics are almost always the better alternative

Skip generics when:

  • A concrete type works fine — if you only ever work with User, just type it as User
  • You're not preserving type information — if T appears only in the parameter but not in the return, you probably don't need it
  • Readability suffers — if coworkers can't understand the type signature, simplify it

Conclusion

TypeScript generics aren't a separate feature you bolt on when things get complicated. They're the foundational mechanism that makes the entire type system work. Every Array<T>, every Promise<T>, every Record<K, V> — they're all generics.

The mental model is simple: generics are functions for types. They take type inputs, process them through constraints and conditionals, and produce type outputs. Once you internalize this, you stop memorizing syntax and start seeing patterns.

Start with the basics: one type parameter, one constraint. Build up to conditional types and mapped types as the problem demands. And always ask yourself: "Would a concrete type work here?" If yes, skip the generic. The best type-level code is the simplest code that preserves the information you need.

The TypeScript type system isn't just a linter. It's a programming language within a programming language. Generics are how you program it.


🛠️ Developer Toolkit: This post first appeared on the Pockit Blog.

Need a Regex Tester, JWT Decoder, or Image Converter? Use them on Pockit.tools or install the Extension to avoid switching tabs. No signup required.

Top comments (0)