Skip to content

TypeScript Generics for Reusable Components

Put generics on component APIs only when they justify the complexity, constraints, inference, and when any is a design smell.

4 min read
TypeScript Generics
React Components
Type Inference
Design System
Frontend

Composition without types that track item shape still forces casts at the edges. Generics are how reusable components keep consumer data honest - Select<T>, Table<T>, FormField<T>, without baking in one domain model.

They are also how libraries become unreadable. I add a type parameter when it removes a cast at more than one call site, not when it decorates a demo.

When a generic pays off

tsx
// packages/ui/src/select.tsx, illustrative type SelectProps<T> = { options: readonly T[]; value: T | null; onChange: (next: T | null) => void; getKey: (option: T) => string; getLabel: (option: T) => string; }; export function Select<T>({ options, value, onChange, getKey, getLabel, }: SelectProps<T>) { return ( <select value={value == null ? '' : getKey(value)} onChange={(e) => { const next = options.find((o) => getKey(o) === e.target.value) ?? null; onChange(next); }} > <option value="">Select…</option> {options.map((o) => ( <option key={getKey(o)} value={getKey(o)} > {getLabel(o)} </option> ))} </select> ); }
tsx
// Consumer: T inferred from options; onChange sees Project <Select options={projects} value={project} onChange={setProject} getKey={(p) => p.id} getLabel={(p) => p.name} />

T pays off because onChange cannot silently receive a string id when the app stores the whole Project. The alternative is any or parallel valueKey props that drift.

Constraints beat unbounded T

Unbounded T invites meaningless instantiations. Constrain what you actually need:

ts
type Entity = { id: string }; type TableProps<T extends Entity> = { rows: readonly T[]; columns: ColumnDef<T>[]; };

Or constrain by capability, not nominal entity:

ts
type Labeled = { label: string }; function ChipList<T extends Labeled>(props: { items: T[] }) { /* ... */ }

If the component only needs id + label, do not require a full domain interface from @acme/billing. Keep the constraint at the capability the UI uses.

Inference versus explicit type arguments

Prefer inference from props (options, rows). Explicit Select<Project> is a smell when inference already works, it usually means the props were typed too weakly (options: any[]) or the call site passed a widened array.

tsx
// Widened array kills inference, fix the data, don't sprinkle <Project> const options = [] as { id: string; name: string }[]; // better: Project[]

Multiple type parameters (TData, TValue, TContext) are justified in table/column libraries and painful in app-level wrappers. If you need three parameters for a Button, stop, the API is wrong.

any escape hatches are design signals

I allow a narrow any in one place only: the boundary with an untyped third-party or a gradual migration. On a public design-system prop, any is a design smell:

SmellPrefer
value: anyvalue: T + infer from options
render: (row: any) => ...render: (row: T) => ...
as: anyconstrained element union (polymorphic post)

Casting at the call site (as Project) once can be honest during migration. Hiding any inside @acme/ui exports the debt to every consumer.

Generics + CVA + polymorphism

Stacking all three is how files hit 400 lines of types. Order the complexity:

  1. CVA variants, visual axes
  2. Narrow polymorphism, hosts
  3. One data generic, item shape
tsx
// Acceptable stacking, illustrative sketch type ListProps<T> = { items: readonly T[]; getKey: (item: T) => string; variant?: 'plain' | 'divided'; // from CVA };

If you also need as + three generics + compound variants, split components. Judgment shows up as surface area, not maximum type fireworks.

Default type parameters: use sparingly

ts
type ListProps<T = { id: string }> = { items: readonly T[]; getKey?: (item: T) => string; };

Defaults help demos and hurt inference when the default is wider than what call sites pass. Prefer required getKey over a default T that pretends every row has id. If most call sites share one entity type, that is a domain wrapper (primitives-to-domain post), not a default type parameter on the primitive.

Review question I ask: "If I delete this type parameter, do call sites start casting?" If no, the generic was decoration, remove it.

Continuity

Composition patterns moved behavior into composable units. Generics type the data those units carry. Conditional and template literal types cover prop relationships and token unions that generics alone cannot express.

This is still TS 5.x-era frontend craft in Sep 2025. I am not writing as if TS 6 / tsgo are the default toolchain; that bridge lands in a later post.

Takeaway

A component generic pays off when it deletes casts at the call site. If it only impresses the type checker, it is ceremony, simplify the API instead.