Frontend

Practical TypeScript Generics for APIs and React Props

I ignored generics for years and sprinkled as casts instead. Once we typed our API client and a few shared components properly, a whole class of production bugs became compile errors.

What you will learn

  • A generic apiGet T turns silent any into checked contracts at call sites.
  • Use extends constraints when the generic body reads fields.
  • Generic React list and select components preserve item types through callbacks.
  • keyof patterns catch invalid field names at compile time.
  • Keep generics shallow — unclear type params are worse than a bit of duplication.
  • Result T E makes expected failure paths visible at the type level.

Start from a pain point, not from theory

Our untyped api.get to /users returned Promise any. Call sites assumed name existed; the backend renamed it to fullName. Runtime crash, green CI. A generic get T would have forced the call site to declare the expected shape — still a contract, but now checked when we updated shared types from OpenAPI.

Generics are functions and types parameterized by other types. I introduce them when the same logic works for multiple shapes and I want the output type to follow the input.

After we generated types from OpenAPI and threaded them through apiGet User, that class of rename bugs became tsc failures in CI. We caught three similar mismatches in the first month — all would have been production 500s before.

Typed fetch wrapper

This is the first generic I add to every TypeScript frontend. The caller passes the response type; errors stay explicit.

I also add apiPost with body and response type parameters so request bodies are checked. Without that, we typed responses carefully and still sent wrong payloads.

export class ApiError extends Error {
  constructor(public status: number, message: string) {
    super(message);
  }
}

export async function apiGet<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(path, {
    ...init,
    headers: { Accept: 'application/json', ...init?.headers },
  });
  if (!res.ok) {
    throw new ApiError(res.status, await res.text());
  }
  return res.json() as Promise<T>;
}

// usage
type User = { id: string; fullName: string };
const user = await apiGet<User>('/api/users/me');
console.log(user.fullName);

Constraints beat unconstrained T

If you need to read id inside the generic function, constrain T. Unconstrained T cannot safely access properties — and if you cast anyway, you lied to the compiler.

Multiple constraints use intersection types. Keep them readable; if the constraint needs three lines, extract a named interface.

function byId<T extends { id: string }>(items: T[], id: string): T | undefined {
  return items.find((item) => item.id === id);
}

const users = [
  { id: '1', fullName: 'Ram' },
  { id: '2', fullName: 'Ada' },
];
const found = byId(users, '2'); // type includes fullName

React props that preserve item types

Shared Select and Table components often erase types with items any array. A generic component keeps item type through render props.

Inference usually works when items is passed inline. If TypeScript widens to a useless type, pass an explicit type argument once. Prefer inference first.

type ListProps<T> = {
  items: T[];
  keyOf: (item: T) => string;
  children: (item: T) => React.ReactNode;
};

export function List<T>({ items, keyOf, children }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={keyOf(item)}>{children(item)}</li>
      ))}
    </ul>
  );
}

// inference works — item is User inside children
<List
  items={users}
  keyOf={(u) => u.id}
  children={(u) => <span>{u.fullName}</span>}
/>;

Defaults and keyof patterns

Generic defaults help when most call sites share a type. keyof plus mapped types power type-safe sorting helpers. I keep these shallow — deep conditional type wizardry slowed our compile times and confused juniors more than it helped.

A Pick or Omit wrapper around API responses is often clearer than a custom conditional type. Reach for the built-ins before inventing a new type-level DSL.

function sortBy<T, K extends keyof T>(items: T[], key: K): T[] {
  return [...items].sort((a, b) => (a[key] > b[key] ? 1 : -1));
}

sortBy(users, 'fullName');
// sortBy(users, 'nope'); // compile error

Result types instead of throwing everywhere

Another place generics shine is a small Result T E for expected failures. Call sites must handle ok false instead of hoping a try/catch exists three layers up.

We use this for form validation and for API calls that can 404 without being exceptional. Throwing remains for truly unexpected failures.

type Result<T, E = string> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function loadUser(id: string): Promise<Result<User, 'not_found' | 'network'>> {
  try {
    const user = await apiGet<User>(`/api/users/${id}`);
    return { ok: true, value: user };
  } catch (err) {
    if (err instanceof ApiError && err.status === 404) {
      return { ok: false, error: 'not_found' };
    }
    return { ok: false, error: 'network' };
  }
}

Generic hooks without pain

Custom hooks benefit from generics the same way components do. useLocalStorage T should return T or undefined and accept values that know T. Without that, every call site casts.

I keep hook generics to one or two type parameters. When a hook needs four type params, it is usually doing too much and should split into smaller hooks or accept a typed adapter object.

function useLocalStorage<T>(key: string, initial: T) {
  const [value, setValue] = useState<T>(() => {
    if (typeof window === 'undefined') return initial;
    const raw = window.localStorage.getItem(key);
    return raw ? (JSON.parse(raw) as T) : initial;
  });

  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}

const [draft, setDraft] = useLocalStorage<{ fullName: string }>('draft', { fullName: '' });

Mapping API DTOs to domain types

Generated OpenAPI types are often noisy. I write small mappers such as toUser from UserDto. Generics help for lists when composed, but inventing a hyper-abstract Mapper with four type params helped nobody. We deleted one of those frameworks after onboarding stalled.

Zod schemas can infer types with z.infer typeof schema, which often removes the need for a hand-written interface plus a generic parse helper. I prefer schema-first when validation already exists at the boundary.

Mistakes that made types worse

Defaulting T to any silently undoes safety. Over-generic components with five type params nobody can invoke. Returning T or any from a helper. Using generics where a simple union of two shapes was clearer.

We also abused React.FC with generics inconsistently; nowadays we type props directly on the function. Measure complexity: if the generic needs a paragraph of explanation in the PR, consider two named functions instead.

Compile-time cost is real. A few heavy conditional types in hot paths added seconds to tsc on our monorepo. We simplified and CI got about 20 percent faster on the typecheck job. My bar for merging a new generic helper: at least three call sites, a constraint that matches real field access, and a PR description that shows a before/after compile error it prevents.

  • Add generics when output types should track input types
  • Constrain with extends when you read properties
  • Prefer inference at call sites over mandatory type arguments
  • Avoid defaulting T to any
  • Generate API types when possible instead of hand-rolling large interfaces
  • Prefer Result or union handling for expected failures over unchecked throws

Key takeaways

  • A generic apiGet T turns silent any into checked contracts at call sites.
  • Use extends constraints when the generic body reads fields.
  • Generic React list and select components preserve item types through callbacks.
  • keyof patterns catch invalid field names at compile time.
  • Keep generics shallow — unclear type params are worse than a bit of duplication.
  • Result T E makes expected failure paths visible at the type level.

About the author

Ram — Founder & Editor, BudhiWorks. I build and ship production web apps — Node, React/Next.js, Postgres, and the boring infrastructure that keeps them online. BudhiWorks is where I publish the guides I wish I had when something broke at 2 a.m.

More about Ram · Contact

← Git Rebase vs Merge: A Safe Workflow We Us… Next: Flexbox vs CSS Grid: When I Use Each in Pr… →