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
| Goal | Pattern | When to skip |
|---|---|---|
| Reuse logic across types | Generic function or interface | Only one concrete type will ever exist |
| Limit generic operations | extends constraint | Constraint is wider than unknown adds no safety |
| Safe property access | keyof + indexed access | Dynamic keys from runtime strings need validation first |
| Optional PATCH fields | Partial<Pick<T, Keys>> | Full replacement PUT can use T directly |
| Unwrap nested promises | Conditional + infer | Use built-in Awaited<T> instead |
| String-keyed event maps | Template literal types | Large cartesian products; use runtime registry |
| External JSON boundaries | Zod + z.infer | Never trust generic alone at the wire |
Common pitfalls
- any poisons inference — a generic called with an
anyargument often becomesany; enablenoImplicitAnyand lint against explicitany. - Over-generic APIs — six type parameters on a helper nobody reuses is harder to read than two concrete overloads.
- Assertion escape hatches —
as Tbypasses 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
tscand IDE hover; simplify or precompute named aliases. - Runtime gap — generics do not validate network input; always parse at the boundary.
Production checklist
- Enable
strictandnoUncheckedIndexedAccessin 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
extendsinstead of casting inside the body. - Prefer
Pick/Omit/Partialover copy-pasted DTO interfaces. - Use
as constfor 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 --noEmitin CI; generic errors caught late are expensive.
Key takeaways
- Generics preserve information — write once, infer precise types per call site.
- Constraints narrow behavior —
extendsdocuments required shape. - Mapped and utility types reduce DTO drift — derive PATCH and pick lists from the entity.
- Conditional types extract and filter — use
infersparingly and name the result. - Compile-time types need runtime partners — Zod at the wire, generics inside the app.
Related reading
- TypeScript fundamentals explained — primitives, unions, narrowing, and strict mode
- Zod fundamentals explained — runtime schemas and
z.infertype inference - tRPC fundamentals explained — end-to-end typed APIs from router to client
- React fundamentals explained — typed components, hooks, and props