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:
tstype Entity = { id: string }; type TableProps<T extends Entity> = { rows: readonly T[]; columns: ColumnDef<T>[]; };
Or constrain by capability, not nominal entity:
tstype 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:
| Smell | Prefer |
|---|---|
value: any | value: T + infer from options |
render: (row: any) => ... | render: (row: T) => ... |
as: any | constrained 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:
- CVA variants, visual axes
- Narrow polymorphism, hosts
- 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
tstype 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.