Guide

TypeScript generics and advanced types explained

Harbor Commerce's merchant fee API shipped with three parallel type definitions: a hand-written interface for REST handlers, a Drizzle row type for Postgres, and a loose any blob in the React admin table. Renaming flatFeeCents to fixedFeeCents compiled in one layer and 500'd in production in another. The fix was not more discipline — it was TypeScript generics and advanced types that derived every surface from shared schemas. A generic ApiResponse<T> wrapper, z.infer from Zod, and mapped types for partial update DTOs meant one rename propagated everywhere. Generics let you write functions, classes, and types that work across many concrete shapes while preserving precise inference. Advanced types — conditional, mapped, template literal, and utility types — transform and compose those shapes at compile time. This guide covers generic syntax and constraints, keyof and indexed access, conditional and mapped types, built-in utilities, template literals and infer, a Harbor Commerce refactor worked example, a pattern decision table, common pitfalls, and a production checklist.

What generics solve (and what they are not)

Without generics, you choose between duplication (fetchUser, fetchOrder, each with its own return type) and erasure (fetchEntity returning unknown or any). Generics parameterize types the same way function arguments parameterize values: function first<T>(arr: T[]): T | undefined preserves the element type through the call. The compiler instantiates T per call site — first(users) returns User | undefined, not unknown.

Generics are a compile-time feature. They erase to JavaScript with no runtime cost. They are not a substitute for runtime validation: a generic signature cannot prove JSON from the network matches T. Pair compile-time types with parsers (Zod, Valibot) at system boundaries. See our TypeScript fundamentals guide for primitives, unions, and narrowing before diving into advanced patterns.

Generic functions, interfaces and constraints

Declare type parameters in angle brackets after the name. Multiple parameters are comma-separated: <T, U>. Default type parameters (<T = string>) let callers omit explicit arguments when the default fits.

Constraints with extends

Unconstrained T only allows operations valid on every type (assignment). To call item.id, constrain: <T extends { id: string }>. Constraints can reference other type parameters: <T extends U, U extends Record<string, unknown>>. Use the extends keyword in generics, not implements (which is for classes).

Generic interfaces and classes

Interfaces and classes can be generic too: interface Repository<T> { findById(id: string): Promise<T | null>; }. Implementations fix or propagate the parameter: class UserRepo implements Repository<User>. Generic classes are common in data structures; generic React components use function List<T>(props: { items: T[]; render: (item: T) => ReactNode }) so list keys and render callbacks stay typed.

keyof, typeof and indexed access types

keyof T produces a union of property names of T. Combined with generics, you get type-safe property access: function get<T, K extends keyof T>(obj: T, key: K): T[K]. The return type T[K] is an indexed access type — it looks up the type of property K on T.

typeof x in type position extracts the TypeScript type of a value. It is essential for const config objects and for inferring types from Zod schemas via typeof schema before z.infer. Pattern: const routes = { home: '/', fees: '/fees' } as const; then type Route = typeof routes[keyof typeof routes] for a union of path literals.

as const and literal preservation

Without as const, TypeScript widens string literals to string and arrays to mutable T[]. as const makes properties readonly and preserves literal types — the foundation for discriminated unions and event name maps used in tRPC routers.

Conditional types

Conditional types use the form T extends U ? X : Y. They distribute over naked type parameters in unions: (A | B) extends U ? X : Y becomes (A extends U ? X : Y) | (B extends U ? X : Y). This powers utility types like Exclude and Extract.

Practical example — unwrap promises: type Awaited<T> = T extends Promise<infer U> ? U : T; The infer keyword declares a type variable inside the true branch, letting you extract nested types (function return types, promise payloads, tuple elements). Over-nesting conditional types hurts readability; two levels are usually enough before extracting a named alias.

Mapped types and built-in utilities

Mapped types iterate keys: type Readonly<T> = { readonly [K in keyof T]: T[K] };. Modifiers +readonly, -readonly, +?, -? add or remove readonly and optional flags. Key remapping uses as: [K in keyof T as `get${Capitalize<string & K>}`] for getter name generation.

Utility types you should know

  • Partial<T> — all properties optional; common for PATCH bodies.
  • Required<T> — opposite of Partial.
  • Pick<T, K> / Omit<T, K> — subset or exclude keys.
  • Record<K, V> — object with keys K and values V.
  • Exclude<T, U> / Extract<T, U> — filter unions.
  • NonNullable<T> — strip null and undefined.
  • ReturnType<F> / Parameters<F> — function introspection.

Prefer built-in utilities over hand-rolled mapped types when they match. Custom mapped types shine when you need domain-specific transforms (e.g. NullableFields<T, 'email' | 'phone'>).

Template literal types

TypeScript can manipulate string literal types: type EventName = `user:${'created' | 'updated' | 'deleted'}` expands to 'user:created' | 'user:updated' | 'user:deleted'. Uppercase, Lowercase, Capitalize, and Uncapitalize intrinsic helpers compose with template literals for CSS property keys, route paths, and SQL column aliases.

Template literals also appear in mapped type keys for renaming patterns. They are powerful but can explode union cardinality — a cartesian product of ten literals with ten literals yields 100 members and slows the compiler. Keep unions small or use branded string types for opaque identifiers instead of enumerating every variant.

Worked example: Harbor Commerce fee API types

Harbor's fee service exposes CRUD for merchant pricing tiers. The refactor introduced a Zod schema as the single source of truth:

const FeeTierSchema = z.object({
  id: z.string().uuid(),
  merchantId: z.string().uuid(),
  fixedFeeCents: z.number().int().nonnegative(),
  percentBps: z.number().int().min(0).max(10000),
  currency: z.enum(['USD', 'EUR', 'GBP']),
});
type FeeTier = z.infer<typeof FeeTierSchema>;
type CreateFeeTier = z.infer<typeof FeeTierSchema.omit({ id: true })>;
type UpdateFeeTier = Partial<Pick<FeeTier, 'fixedFeeCents' | 'percentBps'>>;

A generic API wrapper unified responses: type ApiResult<T> = { ok: true; data: T } | { ok: false; error: string };. The tRPC router imported FeeTier and CreateFeeTier directly; the React admin used ColumnDef<FeeTier>[] from TanStack Table. A mapped type generated display labels: type FeeLabels = { [K in keyof Pick<FeeTier, 'fixedFeeCents' | 'percentBps'>]: string };. Renaming a field broke the build in handlers, ORM mappers, and UI in one compile cycle instead of at deploy time.

Pattern decision table

GoalPatternWhen to skip
Reuse logic across typesGeneric function or interfaceOnly one concrete type will ever exist
Limit generic operationsextends constraintConstraint is wider than unknown adds no safety
Safe property accesskeyof + indexed accessDynamic keys from runtime strings need validation first
Optional PATCH fieldsPartial<Pick<T, Keys>>Full replacement PUT can use T directly
Unwrap nested promisesConditional + inferUse built-in Awaited<T> instead
String-keyed event mapsTemplate literal typesLarge cartesian products; use runtime registry
External JSON boundariesZod + z.inferNever trust generic alone at the wire

Common pitfalls

  • any poisons inference — a generic called with an any argument often becomes any; enable noImplicitAny and lint against explicit any.
  • Over-generic APIs — six type parameters on a helper nobody reuses is harder to read than two concrete overloads.
  • Assertion escape hatchesas T bypasses the type system; fix the generic constraint instead when possible.
  • Variance surprises — mutable arrays and function parameter types are not always assignable the way intuition suggests; prefer readonly when exposing generic collections.
  • Compile-time cost — deeply nested conditional types on large unions slow tsc and IDE hover; simplify or precompute named aliases.
  • Runtime gap — generics do not validate network input; always parse at the boundary.

Production checklist

  • Enable strict and noUncheckedIndexedAccess in tsconfig.
  • Define domain entities once (Zod schema or interface) and infer the rest.
  • Use generic wrappers (ApiResult<T>, Page<T>) for repeated shapes.
  • Constrain generics with extends instead of casting inside the body.
  • Prefer Pick/Omit/Partial over copy-pasted DTO interfaces.
  • Use as const for config objects and route tables.
  • Limit template literal unions to small finite sets.
  • Validate external input at runtime; treat compile-time types as documentation plus local safety.
  • Extract complex conditional types into named exports with JSDoc examples.
  • Run tsc --noEmit in CI; generic errors caught late are expensive.

Key takeaways

  • Generics preserve information — write once, infer precise types per call site.
  • Constraints narrow behaviorextends documents required shape.
  • Mapped and utility types reduce DTO drift — derive PATCH and pick lists from the entity.
  • Conditional types extract and filter — use infer sparingly and name the result.
  • Compile-time types need runtime partners — Zod at the wire, generics inside the app.

Related reading