DevLift
Back to Blog

TypeScript Generics: Write Once, Work Everywhere

Stop writing the same function for every type or reaching for `any`. TypeScript generics let you write it once — with full type inference, constraints, and zero casting.

Admin
August 2, 20265 min read0 views

TypeScript Generics: Write Once, Work Everywhere

At some point you write a utility function and realize it only works for one type. So you write a second version for another type. Then a third. By the fourth you either reach for any — which silently destroys every guarantee TypeScript was giving you — or you copy-paste the same logic into yet another file and swear at yourself quietly.

Generics are the fix. They let you write a function, interface, or class once and have TypeScript figure out the concrete types at each call site. No any, no duplicates, no loss of inference.

The Problem: Copy-Paste or Give Up

Here's a function that wraps an API response in a consistent envelope:

// Bad: only works for User responses
function wrapUserResponse(data: User, success: boolean) {
  return { data, success, timestamp: new Date() };
}
 
// Bad: same code, different type
function wrapPostResponse(data: Post, success: boolean) {
  return { data, success, timestamp: new Date() };
}
 
// Also bad: you've thrown away all type information
function wrapAnyResponse(data: any, success: boolean) {
  return { data, success, timestamp: new Date() };
}

Three problems here. The first two are duplicates — any change to the envelope structure has to be made in N places. The third compiles fine but now wrapAnyResponse(...).data is any: you've lost the type of whatever you passed in.

The Pattern: Generic Functions

A generic function takes a type parameter — written inside <T> — that gets substituted at each call site based on what you actually pass:

function wrapResponse<T>(data: T, success: boolean) {
  return {
    data,       // type is T
    success,
    timestamp: new Date(),
  };
}
 
// TypeScript infers T = User from the argument
const userResponse = wrapResponse(user, true);
// userResponse.data is User — full autocomplete, no assertions
 
const postResponse = wrapResponse(post, true);
// postResponse.data is Post

One function. TypeScript infers the type from whatever you pass in. You almost never need to write wrapResponse<User>(user, true) explicitly — the inference handles it.

Rendering diagram...

Constraining Generics with extends

Without constraints, T can be literally anything — string, null, () => void. That's useful sometimes, but often you need to guarantee the type has certain properties.

Shape constraints:

// T must have an `id` field
function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
  return items.find((item) => item.id === id);
}
 
const user = findById(users, "u_123");   // User | undefined
const post = findById(posts, "p_456");   // Post | undefined
 
// TypeScript rejects this — numbers don't have `id`
findById([1, 2, 3], "123"); // Error

keyof constraints — type-safe property access:

This is one of the most useful generic patterns you'll actually reach for:

// K must be a key of T — prevents accessing properties that don't exist
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}
 
const emails = pluck(users, "email");   // string[]
const ids = pluck(users, "id");         // string[]
 
// Compile error — "password" doesn't exist on User
pluck(users, "password"); // Property 'password' does not exist

Without K extends keyof T, you'd need to cast or use any. With it, TypeScript knows the return type is T[K] — the actual type of that property.

Generic Interfaces and Types

Functions aren't the only place generics show up. Interfaces and type aliases are where you'll use them most often in practice:

// A standard API response wrapper
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}
 
// A paginated list response
interface PaginatedResponse<T> {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
  hasNextPage: boolean;
}
 
// Usage — the concrete type flows through automatically
async function fetchUsers(): Promise<ApiResponse<User[]>> {
  const res = await fetch("/api/users");
  return res.json();
}
 
async function fetchPosts(page: number): Promise<PaginatedResponse<Post>> {
  const res = await fetch(`/api/posts?page=${page}`);
  return res.json();
}

Now fetchUsers().then(r => r.data) gives you User[] with full autocomplete. No casting needed.

Generic interfaces for callbacks and handlers:

interface EventHandler<TPayload> {
  (payload: TPayload): void | Promise<void>;
}
 
// Typed event bus
interface EventMap {
  "user:created": { userId: string; email: string };
  "user:deleted": { userId: string };
  "payment:completed": { amount: number; currency: string; orderId: string };
}
 
function on<K extends keyof EventMap>(
  event: K,
  handler: EventHandler<EventMap[K]>
): void {
  // register handler
}
 
on("user:created", ({ userId, email }) => {
  // userId is string, email is string — TypeScript knows
  console.log(`New user: ${email}`);
});
 
on("payment:completed", ({ amount, currency }) => {
  // amount is number, currency is string
  console.log(`Payment: ${amount} ${currency}`);
});
 
// Error — payload type doesn't match
on("user:created", ({ amount }) => {}); // Property 'amount' does not exist

Default Type Parameters

Generics can have defaults, just like function parameters:

interface RequestConfig<TBody = Record<string, unknown>> {
  url: string;
  method: "GET" | "POST" | "PUT" | "DELETE";
  body?: TBody;
  headers?: Record<string, string>;
}
 
// Uses the default — body is Record<string, unknown>
const config: RequestConfig = { url: "/api/data", method: "GET" };
 
// Override the default
const loginConfig: RequestConfig<{ email: string; password: string }> = {
  url: "/api/login",
  method: "POST",
  body: { email: "user@example.com", password: "secret" },
};

Defaults prevent the type parameter from being mandatory when the caller doesn't care about it.

Conditional Types and infer

This is where generics get genuinely powerful — and where most tutorials stop being useful because the examples are too abstract. Let's look at real cases.

The infer keyword lets TypeScript extract a type from within another type:

// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer R> ? R : T;
 
type UserData = Awaited<Promise<User>>;   // User
type StringData = Awaited<string>;         // string (not a Promise, returns as-is)

(This is actually how the built-in Awaited<T> utility type works.)

Extracting array element types:

type ElementOf<T> = T extends (infer E)[] ? E : never;
 
type PostElement = ElementOf<Post[]>;   // Post
type NumElement = ElementOf<number[]>;  // number
type Nope = ElementOf<string>;           // never — not an array

Function parameter and return type extraction:

type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;
type ParamsOf<T> = T extends (...args: infer P) => any ? P : never;
 
declare function fetchUser(id: string): Promise<User>;
 
type FetchUserReturn = ReturnOf<typeof fetchUser>; // Promise<User>
type FetchUserParams = ParamsOf<typeof fetchUser>; // [string]

These are essentially how TypeScript's built-in ReturnType<T> and Parameters<T> utility types are implemented.

Practical use: a typed API fetcher

type ApiRoute = {
  "/users": { GET: User[]; POST: { body: CreateUserInput; response: User } };
  "/posts": { GET: Post[]; POST: { body: CreatePostInput; response: Post } };
  "/users/:id": { GET: User; DELETE: void };
};
 
// Extract the GET response type for a route
type GetResponseFor<Route extends keyof ApiRoute> =
  ApiRoute[Route] extends { GET: infer R } ? R : never;
 
type UsersGetResponse = GetResponseFor<"/users">;    // User[]
type UserGetResponse = GetResponseFor<"/users/:id">; // User
💡

infer only works inside conditional types. You can't use it in function bodies or standalone type positions — only in the extends clause of a conditional type.

A Real Pattern: Generic Repository

Here's a pattern that shows up constantly in backend TypeScript code — a generic repository that gives you typed CRUD operations without reimplementing them per model:

interface Repository<TModel, TCreateInput, TUpdateInput = Partial<TCreateInput>> {
  findById(id: string): Promise<TModel | null>;
  findMany(filter?: Partial<TModel>): Promise<TModel[]>;
  create(data: TCreateInput): Promise<TModel>;
  update(id: string, data: TUpdateInput): Promise<TModel>;
  delete(id: string): Promise<void>;
}
 
// Concrete implementation for a specific model
class UserRepository implements Repository<User, CreateUserInput> {
  async findById(id: string): Promise<User | null> {
    return prisma.user.findUnique({ where: { id } });
  }
 
  async findMany(filter?: Partial<User>): Promise<User[]> {
    return prisma.user.findMany({ where: filter });
  }
 
  async create(data: CreateUserInput): Promise<User> {
    return prisma.user.create({ data });
  }
 
  async update(id: string, data: Partial<CreateUserInput>): Promise<User> {
    return prisma.user.update({ where: { id }, data });
  }
 
  async delete(id: string): Promise<void> {
    await prisma.user.delete({ where: { id } });
  }
}

The Repository<User, CreateUserInput> interface is a contract. Any code that depends on Repository<User, CreateUserInput> can be tested with a mock that also implements the same interface. The generic parameters are what make that swappable.

Generic React Components

In React, generics let you build typed UI components that work with any data shape:

// A typed Select component that preserves the option type
interface SelectProps<T> {
  options: T[];
  value: T | null;
  onChange: (value: T) => void;
  getLabel: (option: T) => string;
  getValue: (option: T) => string;
}
 
function Select<T>({ options, value, onChange, getLabel, getValue }: SelectProps<T>) {
  return (
    <select
      value={value ? getValue(value) : ""}
      onChange={(e) => {
        const selected = options.find((o) => getValue(o) === e.target.value);
        if (selected) onChange(selected);
      }}
    >
      {options.map((option) => (
        <option key={getValue(option)} value={getValue(option)}>
          {getLabel(option)}
        </option>
      ))}
    </select>
  );
}
 
// Usage — onChange gives you User, not string
<Select<User>
  options={users}
  value={selectedUser}
  onChange={(user) => setSelectedUser(user)} // user is User, not string
  getLabel={(u) => u.name}
  getValue={(u) => u.id}
/>

The key insight: onChange gets a User back, not a string. Without generics, you'd return a string ID and have to look up the object yourself every time.

In .tsx files, write <T,> (with a trailing comma) to disambiguate from JSX — <T> alone looks like an opening JSX tag to the compiler.

Mapped Types: Transforming Every Key

Mapped types iterate over union types and create a new type from them. They're how utility types like Partial<T>, Readonly<T>, and Record<K, V> work:

// All fields optional
type Partial<T> = { [K in keyof T]?: T[K] };
 
// All fields readonly
type Readonly<T> = { readonly [K in keyof T]: T[K] };
 
// Transform every value type
type Stringify<T> = { [K in keyof T]: string };
 
type StringifiedUser = Stringify<User>;
// { id: string; email: string; name: string; createdAt: string; ... }

A practical one — making specific fields nullable for a form state:

type FormState<T> = {
  [K in keyof T]: {
    value: T[K];
    error: string | null;
    touched: boolean;
  };
};
 
type LoginFormState = FormState<{ email: string; password: string }>;
// {
//   email: { value: string; error: string | null; touched: boolean };
//   password: { value: string; error: string | null; touched: boolean };
// }

When NOT to Use Generics

Generics are easy to overuse. A few signs you've gone too far:

When unknown or a union type is clearer. If you just want to accept "anything and return it unchanged", reach for unknown instead of T. It's more honest about what the function actually does.

When you're adding T just to pass it through. If T doesn't appear in the return type or any constraint, it's not doing work:

// Pointless — T is never used in a meaningful way
function logGeneric<T>(value: T): void {
  console.log(value);
}
 
// Better
function log(value: unknown): void {
  console.log(value);
}

When the caller never gets different types back. If doThing<User>() and doThing<Post>() produce the same return type regardless of T, the generic isn't providing safety — it's just noise.

When you reach for as unknown as T anywhere in the implementation. That's the generic equivalent of any. It compiles, but TypeScript isn't actually verifying anything anymore.

🚨

as unknown as T inside a generic function is a red flag. It means the type system can't verify your claim. If you find yourself writing it, double-check that the generic is actually the right tool for the job.

One-off transformations. A utility that runs exactly once in your codebase doesn't need a generic. The abstraction cost isn't worth it.

Putting It Together: A Typed Cache

Here's a small, complete example that combines several of these ideas:

interface CacheEntry<T> {
  value: T;
  expiresAt: number;
}
 
class TypedCache<TKey extends string, TValue> {
  private store = new Map<TKey, CacheEntry<TValue>>();
 
  set(key: TKey, value: TValue, ttlMs: number): void {
    this.store.set(key, {
      value,
      expiresAt: Date.now() + ttlMs,
    });
  }
 
  get(key: TKey): TValue | null {
    const entry = this.store.get(key);
    if (!entry || Date.now() > entry.expiresAt) {
      this.store.delete(key);
      return null;
    }
    return entry.value;
  }
 
  has(key: TKey): boolean {
    return this.get(key) !== null;
  }
}
 
// User session cache — keys must be strings, values are Session objects
const sessionCache = new TypedCache<string, Session>();
sessionCache.set("sess_abc", session, 15 * 60 * 1000); // 15 min TTL
 
const cached = sessionCache.get("sess_abc"); // Session | null

TKey extends string isn't strictly necessary here (Map already accepts string keys), but it documents intent: only string keys are valid, and TypeScript will enforce that at the call site.

The Mental Model

Generics are type-level functions. Just as a regular function takes a value and returns a value, a generic takes a type and returns a type. wrapResponse<T> takes T = User and returns { data: User; success: boolean; timestamp: Date }.

Once that clicks, the extends keyword reads naturally — it's the type-level equivalent of a function parameter type annotation. <T extends { id: string }> is just saying: "this type parameter must satisfy this shape constraint."

And infer is pattern matching on types — the same idea as destructuring a value, but for extracting sub-types from a composite type.

Use generics when:

  • The same logic needs to work across multiple types with the same structure
  • You want callers to get back the exact type they put in (not a widened union)
  • You're building a reusable library, utility, or component that others will use with their own types

Avoid them when:

  • You're handling a genuinely heterogeneous collection where unknown[] is accurate
  • The generic doesn't appear in the return type or any constraint (it's not doing work)
  • The implementation requires you to cast away type safety to make it compile

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read