Most TypeScript generics tutorials start with function identity<T>(x: T): T. That example is useful for explaining the syntax and useless for understanding when to reach for generics in real code.

Generics earn their place when one piece of code needs to work over many types while preserving what each type means at the call site. Repositories, API clients, hooks, form handlers — all of these benefit when you let the compiler track the type instead of the developer.

Generics Are Reusable Shape, Not Fancy Syntax

The minimal mental model: a generic is a type-level parameter. You say "this function works on some T," and the compiler tracks what T actually is at every call site.

TypeScript src/api/client.ts
type ApiResponse<T> = {
  data: T;
  requestId: string;
};

async function getJson<T>(url: string): Promise<ApiResponse<T>> {
  const response = await fetch(url);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json() as Promise<ApiResponse<T>>;
}

const user = await getJson<{ id: string; email: string }>('/api/me');
// user.data.email is typed — no manual casting at the call site

One implementation. Every endpoint gets typed responses. Without the generic, you'd either lose typing entirely (Promise<unknown>) or write a separate function per endpoint.

Backend Repositories Are A Good Starting Point

A generic repository is the textbook example for a reason — it really does eliminate a class of repetition.

TypeScript src/repositories/Repository.ts
interface Repository<T extends { id: string }> {
  findById(id: string): Promise<T | null>;
  save(entity: T): Promise<void>;
  delete(id: string): Promise<void>;
}

class UserRepository implements Repository<User> { /* ... */ }
class OrderRepository implements Repository<Order> { /* ... */ }

The extends { id: string } constraint is doing real work — it tells the compiler that whatever T is, it must have an id field, so findById can rely on that shape.

A useful rule: only add a generic when you have at least two real call sites that would benefit. One usage is just a more verbose way to write the concrete type.

Inline conveyor diagram showing Repository&lt;T&gt; on the left and four call sites — Repository&lt;User&gt;, Repository&lt;Product&gt;, Repository&lt;Order&gt;, Repository&lt;Invoice&gt; — each producing a typed findById result.
Generic factory: same code, many types, type preserved at every call site.

API Clients Need Typed Data

A generic API client is where the win is most visible day-to-day. The same apiClient.get(...) call returns a different type depending on the schema you point it at.

TypeScript src/api/typed-client.ts
import { z, ZodSchema } from 'zod';

async function get<T>(url: string, schema: ZodSchema<T>): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return schema.parse(await res.json());
}

const UserSchema = z.object({ id: z.string(), email: z.string().email() });
const user = await get('/api/me', UserSchema);
// user is typed and validated — both at compile and runtime

Two wins in one line. The compiler knows the type because of the generic. The runtime knows the shape because of the schema. No manual casting, no as User, no lying to yourself.

Frontend Hooks And Forms Benefit Too

React hooks are a natural fit for generics — you usually write one hook used by many components with different state shapes.

TypeScript src/hooks/useFetch.ts
function useFetch<T>(url: string, schema: ZodSchema<T>) {
  const [state, setState] = useState<
    | { kind: 'idle' }
    | { kind: 'loading' }
    | { kind: 'loaded'; data: T }
    | { kind: 'error'; error: Error }
  >({ kind: 'idle' });

  // ... fetch logic ...
  return state;
}

const userState = useFetch('/api/me', UserSchema);
if (userState.kind === 'loaded') {
  console.log(userState.data.email); // typed
}

The generic plus the discriminated union means the consuming component can switch on state.kind and the compiler narrows the type for each case. That's not just convenient — it's how impossible states get prevented.

When To Skip Generics

A real test before adding <T> to anything: would two real call sites use different types and need to preserve each one? If the answer is "maybe someday," skip it. A concrete type today is easier to read than a generic that exists in case of future need.

The same goes for constraints. T extends Record<string, unknown> sounds clever and often means "I don't actually know what this should be." Be specific or be honest about being generic.

Pro Tips

  1. Constrain generics meaningfully. T extends { id: string } is real; T extends object is noise.
  2. Prefer infer over re-typing. Awaited<T>, ReturnType<T>, and Parameters<T> are usually what you want.
  3. Don't generic for one call site. Concrete first; generic when the second call site appears.
  4. Pair generics with schemas at network boundaries. Compile-time + runtime safety in one definition.
  5. Read the inferred type. If hovering shows something incomprehensible, simplify.

Final Tips

Generics are one of the few TypeScript features where less is genuinely more. Three well-placed generics in a codebase do more for productivity than thirty clever ones. Most of the value comes from a typed apiClient, a typed Repository, and a typed useQuery — patterns that pay back every day without anyone having to learn a new mental model.

Use them where they remove duplication. Skip them where they add only complexity.

Good luck — and may your hover tooltips stay readable 👊