Blog/Tutorials & Step-by-Step/Mastering TypeScript Generics for Better APIs
POST
February 15, 2026
LAST UPDATEDFebruary 15, 2026

Mastering TypeScript Generics for Better APIs

Generics can be intimidating, but they are the secret to writing scalable, type-safe, and reusable code in TypeScript. Here's a deep dive into practical use cases.

Tags

TypeScriptFrontendArchitecture
Mastering TypeScript Generics for Better APIs
4 min read

Mastering TypeScript Generics for Better APIs

TL;DR

TypeScript generics let you write flexible, reusable functions and components that preserve full type information, eliminating the need for any while keeping your code DRY and type-safe.

Prerequisites

  • TypeScript 5+ configured in a project
  • Solid understanding of TypeScript basics (interfaces, types, type inference)
  • Familiarity with functions, classes, and async patterns
  • A code editor with TypeScript language server (VS Code recommended)

Step 1: Understanding the Problem Generics Solve

Without generics, you face an uncomfortable choice: duplicate code for each type, or use any and lose type safety.

typescript
// Option A: Duplicate code for every type
function getFirstString(arr: string[]): string | undefined {
  return arr[0];
}
function getFirstNumber(arr: number[]): number | undefined {
  return arr[0];
}
 
// Option B: Use "any" and lose type information
function getFirst(arr: any[]): any {
  return arr[0];
}
const result = getFirst(["hello", "world"]);
// result is "any" -- TypeScript cannot help you here

The Generic Solution

Generics introduce a type variable that captures the input type and carries it through to the output.

typescript
function getFirst<T>(arr: T[]): T | undefined {
  return arr[0];
}
 
const str = getFirst(["hello", "world"]); // type: string | undefined
const num = getFirst([1, 2, 3]);          // type: number | undefined
const user = getFirst([{ name: "Alice" }]); // type: { name: string } | undefined

TypeScript infers T from the argument. You can also specify it explicitly when inference is ambiguous:

typescript
const result = getFirst<string>(["hello"]);

Step 2: Generic Functions

Build utility functions that work with any type while preserving type safety.

typescript
// A generic function that groups array items by a key
function groupBy<T, K extends keyof T>(items: T[], key: K): Record<string, T[]> {
  return items.reduce((groups, item) => {
    const groupKey = String(item[key]);
    if (!groups[groupKey]) {
      groups[groupKey] = [];
    }
    groups[groupKey].push(item);
    return groups;
  }, {} as Record<string, T[]>);
}
 
interface Product {
  id: number;
  name: string;
  category: string;
  price: number;
}
 
const products: Product[] = [
  { id: 1, name: "Laptop", category: "electronics", price: 999 },
  { id: 2, name: "Shirt", category: "clothing", price: 29 },
  { id: 3, name: "Phone", category: "electronics", price: 699 },
];
 
const grouped = groupBy(products, "category");
// grouped.electronics -> Product[]
// grouped.clothing -> Product[]
 
// TypeScript catches invalid keys at compile time:
// groupBy(products, "invalid"); // Error: Argument of type '"invalid"' is not assignable

Multiple Type Parameters

Functions can accept multiple generic parameters when the types are independent.

typescript
function zip<A, B>(arr1: A[], arr2: B[]): [A, B][] {
  const length = Math.min(arr1.length, arr2.length);
  const result: [A, B][] = [];
  for (let i = 0; i < length; i++) {
    result.push([arr1[i], arr2[i]]);
  }
  return result;
}
 
const pairs = zip(["a", "b", "c"], [1, 2, 3]);
// type: [string, number][]
// value: [["a", 1], ["b", 2], ["c", 3]]

Step 3: Generic Interfaces and Types

Generics in interfaces create reusable data shape definitions.

typescript
// A generic API response wrapper
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: string;
}
 
interface PaginatedResponse<T> {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
  hasNextPage: boolean;
}
 
// Usage with specific types
interface User {
  id: string;
  name: string;
  email: string;
}
 
interface Post {
  id: string;
  title: string;
  body: string;
  authorId: string;
}
 
type UserResponse = ApiResponse<User>;
type PostListResponse = ApiResponse<PaginatedResponse<Post>>;
 
// The types are fully resolved:
// PostListResponse.data.items[0].title -> string

Generic Type Aliases

typescript
// A generic Result type for error handling (like Rust's Result)
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
 
function parseJSON<T>(json: string): Result<T> {
  try {
    const value = JSON.parse(json) as T;
    return { ok: true, value };
  } catch (error) {
    return { ok: false, error: error as Error };
  }
}
 
const result = parseJSON<User>('{"id": "1", "name": "Alice", "email": "alice@example.com"}');
 
if (result.ok) {
  console.log(result.value.name); // TypeScript knows this is a User
} else {
  console.error(result.error.message); // TypeScript knows this is an Error
}

Step 4: Generic Constraints with extends

Constraints limit which types can be used as a generic parameter, ensuring the generic type has properties your code depends on.

typescript
// Without constraint - this fails
function getLength<T>(item: T): number {
  // return item.length; // Error: Property 'length' does not exist on type 'T'
  return 0;
}
 
// With constraint - T must have a length property
function getLength<T extends { length: number }>(item: T): number {
  return item.length; // Works because T is guaranteed to have 'length'
}
 
getLength("hello");       // OK: string has .length
getLength([1, 2, 3]);     // OK: array has .length
getLength({ length: 10 }); // OK: object has .length
// getLength(42);          // Error: number does not have .length

The keyof Constraint Pattern

One of the most useful constraint patterns is limiting a generic to the keys of another type.

typescript
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const result = {} as Pick<T, K>;
  for (const key of keys) {
    result[key] = obj[key];
  }
  return result;
}
 
const user = { id: "1", name: "Alice", email: "alice@example.com", role: "admin" };
 
const picked = pick(user, ["name", "email"]);
// type: Pick<typeof user, "name" | "email">
// value: { name: "Alice", email: "alice@example.com" }
 
// TypeScript prevents picking invalid keys:
// pick(user, ["name", "invalid"]); // Error

Step 5: Default Type Parameters

Like function parameters, generics can have default values.

typescript
interface FetchOptions<TData = unknown, TError = Error> {
  url: string;
  onSuccess?: (data: TData) => void;
  onError?: (error: TError) => void;
  retries?: number;
}
 
// Uses defaults: TData = unknown, TError = Error
function fetchData(options: FetchOptions): void {
  // ...
}
 
// Specifies TData, uses default TError
function fetchUser(options: FetchOptions<User>): void {
  // options.onSuccess receives User
}
 
// Specifies both
interface ApiError {
  code: string;
  message: string;
  details?: Record<string, string>;
}
 
function fetchWithCustomError(options: FetchOptions<User, ApiError>): void {
  // options.onError receives ApiError
}

Step 6: Conditional Types

Conditional types select a type based on a condition, enabling type-level logic.

typescript
// Basic conditional type
type IsString<T> = T extends string ? true : false;
 
type A = IsString<"hello">; // true
type B = IsString<42>;      // false
 
// Practical example: Extract the return type of a function
type AsyncReturnType<T> = T extends (...args: any[]) => Promise<infer R> ? R : never;
 
async function getUser(): Promise<User> {
  return { id: "1", name: "Alice", email: "alice@example.com" };
}
 
type UserResult = AsyncReturnType<typeof getUser>; // User

The infer Keyword

infer lets you extract types from within other types.

typescript
// Extract the element type from an array
type ElementOf<T> = T extends (infer E)[] ? E : never;
 
type StringElement = ElementOf<string[]>; // string
type UserElement = ElementOf<User[]>;     // User
 
// Extract event payload type from an event handler
type EventPayload<T> = T extends (event: infer P) => void ? P : never;
 
type ClickHandler = (event: MouseEvent) => void;
type ClickPayload = EventPayload<ClickHandler>; // MouseEvent

Step 7: Real-World Patterns

Type-Safe API Client

Build a fetch wrapper that infers response types from endpoint definitions.

typescript
interface ApiEndpoints {
  "/users": { response: User[]; params: { role?: string } };
  "/users/:id": { response: User; params: { id: string } };
  "/posts": { response: Post[]; params: { page?: number; limit?: number } };
  "/posts/:id": { response: Post; params: { id: string } };
}
 
async function apiClient<T extends keyof ApiEndpoints>(
  endpoint: T,
  params?: ApiEndpoints[T]["params"]
): Promise<ApiEndpoints[T]["response"]> {
  let url: string = endpoint;
 
  // Replace path parameters
  if (params) {
    for (const [key, value] of Object.entries(params)) {
      url = url.replace(`:${key}`, String(value));
    }
  }
 
  const response = await fetch(`/api${url}`);
  return response.json();
}
 
// Fully type-safe usage:
const users = await apiClient("/users", { role: "admin" }); // User[]
const user = await apiClient("/users/:id", { id: "123" });   // User
const posts = await apiClient("/posts", { page: 1 });        // Post[]
 
// TypeScript catches mistakes:
// await apiClient("/invalid");                // Error: not a valid endpoint
// await apiClient("/users", { invalid: true }); // Error: invalid param

Type-Safe Form Handler

Create a form handler that infers field types from a schema definition.

typescript
type FieldType = "text" | "number" | "email" | "checkbox";
 
interface FieldConfig {
  type: FieldType;
  required?: boolean;
  label: string;
}
 
type InferFieldValue<T extends FieldConfig> = T["type"] extends "checkbox"
  ? boolean
  : T["type"] extends "number"
  ? number
  : string;
 
type InferFormValues<T extends Record<string, FieldConfig>> = {
  [K in keyof T]: InferFieldValue<T[K]>;
};
 
function createForm<T extends Record<string, FieldConfig>>(
  schema: T
): {
  schema: T;
  validate: (values: InferFormValues<T>) => boolean;
  getDefaults: () => InferFormValues<T>;
} {
  return {
    schema,
    validate: (values) => {
      for (const [key, config] of Object.entries(schema)) {
        if (config.required && !values[key as keyof typeof values]) {
          return false;
        }
      }
      return true;
    },
    getDefaults: () => {
      const defaults = {} as InferFormValues<T>;
      for (const [key, config] of Object.entries(schema)) {
        const k = key as keyof InferFormValues<T>;
        if (config.type === "checkbox") {
          (defaults as any)[k] = false;
        } else if (config.type === "number") {
          (defaults as any)[k] = 0;
        } else {
          (defaults as any)[k] = "";
        }
      }
      return defaults;
    },
  };
}
 
// Usage:
const loginForm = createForm({
  email: { type: "email", required: true, label: "Email" },
  password: { type: "text", required: true, label: "Password" },
  rememberMe: { type: "checkbox", label: "Remember me" },
});
 
const defaults = loginForm.getDefaults();
// type: { email: string; password: string; rememberMe: boolean }
 
loginForm.validate({
  email: "alice@example.com",
  password: "secret",
  rememberMe: true,
}); // true

Generic React Component

Build a generic list component that preserves item types.

tsx
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string;
  emptyMessage?: string;
}
 
function List<T>({ items, renderItem, keyExtractor, emptyMessage }: ListProps<T>) {
  if (items.length === 0) {
    return <p>{emptyMessage || "No items"}</p>;
  }
 
  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}
 
// Usage - TypeScript infers the item type from the items array
<List
  items={users}
  keyExtractor={(user) => user.id}       // user is typed as User
  renderItem={(user) => <span>{user.name}</span>} // user is typed as User
/>

Putting It All Together

Generics are the backbone of type-safe, reusable TypeScript code. The progression from basic to advanced follows a clear path:

  1. Generic functions -- write once, use with any type (getFirst<T>)
  2. Generic interfaces -- define reusable data shapes (ApiResponse<T>)
  3. Constraints -- limit generics to types with required properties (T extends { length: number })
  4. Default types -- provide sensible fallbacks (FetchOptions<TData = unknown>)
  5. Conditional types -- type-level branching and inference (T extends Promise<infer R> ? R : never)
  6. Real-world composition -- combine all patterns for type-safe API clients, form handlers, and component libraries

The key insight is that generics flow type information through your code. When you call apiClient("/users"), TypeScript traces the generic through the endpoint map, resolves the response type, and gives you autocompletion on the result. No runtime cost, full compile-time safety.

Next Steps

  • Mapped types -- learn Record, Partial, Required, and Readonly to transform types systematically
  • Template literal types -- use string manipulation at the type level for route patterns and event names
  • Recursive types -- define types that reference themselves for trees, nested forms, and JSON schemas
  • Declaration merging -- extend third-party library types with additional generic parameters
  • Variance annotations -- use in and out keywords to control generic covariance and contravariance

FAQ

What are TypeScript generics and why do they matter?

Generics are type parameters that let you write functions and classes that work with multiple types while preserving type safety. Without them, you would either duplicate code for each type or lose type information by using any.

When should you use generics vs union types?

Use generics when the output type depends on the input type (like a function that returns the same type it receives). Use union types when a value can be one of a fixed set of known types.

How do generic constraints work in TypeScript?

Generic constraints use the extends keyword to limit what types can be passed as a generic parameter, ensuring the generic type has certain properties or capabilities that your code depends on.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

How to Add Observability to a Node.js App with OpenTelemetry
Mar 21, 20265 min read
Node.js
OpenTelemetry
Observability

How to Add Observability to a Node.js App with OpenTelemetry

Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
Mar 21, 20266 min read
Next.js
Node.js
BFF

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js

A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.

How I Structure CI/CD for Next.js, Docker, and GitHub Actions
Mar 21, 20265 min read
CI/CD
Next.js
Docker

How I Structure CI/CD for Next.js, Docker, and GitHub Actions

A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.