Topic 14.5
Generic and Polymorphic Components
In one line
A generic component takes a type parameter so one List or Select works for products, users or sizes and keeps the exact type in its callbacks. A polymorphic component takes an as prop and borrows the props of whatever element it becomes.
Think of it like this
A lunchbox with a label that says "holds ONE kind of food". Pack apples and the lid sticker says "apple lunchbox"; every spoon, napkin and knife you reach for from it is apple-sized. A generic component is that lunchbox: you tell it the kind of thing it holds once (Product), and everything it hands back is of that kind. A polymorphic component is a lunchbox that can also turn into a bowl or a bag when you ask, and then follows the rules of that new container.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Generic component
- A component with a type parameter, so one component works for many kinds of data and keeps the exact type.
- Type parameter
- A placeholder like
Tthat is filled in with a real type each time you use the component. - Constraint
- A rule on a type parameter written
T extends ...that limits what T can be. - Inference
- TypeScript working out
Tfrom the props you pass, so you don't write it. - Polymorphic component
- A component that can render as different elements, chosen with an
asprop. - ElementType
- React's type for anything that can be used as a tag:
"div","a", or a component. - ComponentPropsWithoutRef
- The props of an element or component, without
ref. A good base for wrappers. - Omit
- A utility type that removes some keys from an object type (Topic 10.1).
- Forwarding props
- Accepting every prop of an inner element and passing them on with
{...rest}.
Step by step
01Start with the loose version and feel the pain
A List that takes items: unknown[] and renderItem: (item: unknown) => ReactNode compiles, but every caller has to cast: (item as Product).name. Nothing stops a cast to the wrong type.
02Add a type parameter
Write ListProps<T> and function List<T>(...). Now items, getKey and renderItem share one T.
Callers don't write T: TypeScript reads it from items. In the first example, hover p in renderItem={(p) => ...}: it is a Product.
type ListProps<T> = {
items: T[];
getKey: (item: T) => string | number;
renderItem: (item: T) => ReactNode;
};
function List<T>({ items, getKey, renderItem }: ListProps<T>) {
return <ul>{items.map((item) => <li key={getKey(item)}>{renderItem(item)}</li>)}</ul>;
}03Constrain when the body needs something
Select<T extends string | number> lets the body call String(option) for the <option value> and use option as a key.
A native <select> always reports a string, so the component looks up which real option matches and calls onChange with that option, typed T. The parent never sees strings.
04Remember the .tsx rule for arrow functions
In .tsx, const List = <T>(props: P) => ... is read as JSX. Use <T,> or a function declaration.
// fails to parse in .tsx
// const first = <T>(items: T[]) => items[0];
const first = <T,>(items: T[]) => items[0];
function firstToo<T>(items: T[]) { return items[0]; }05The as prop
C extends ElementType is the element. as?: C picks it, with a default of "span". ComponentPropsWithoutRef<C> gives the props of that element, minus the names we define ourselves.
TypeScript infers C from the as you pass, so as="a" allows href and as="span" does not.
type TextProps<C extends ElementType> = {
as?: C;
children: ReactNode;
} & Omit<ComponentPropsWithoutRef<C>, "as" | "children">;
function Text<C extends ElementType = "span">({ as, children, ...rest }: TextProps<C>) {
const Tag: ElementType = as ?? "span";
return <Tag {...rest}>{children}</Tag>;
}06Forward props to one element
For a fixed element you don't need generics. Take its props, remove what you set yourself, and spread the rest. The fourth example removes id because the component creates its own with useId.
Try it yourself
- 1
Typo inside the callback
In the List example, change
p.nametop.nme. Predict the message and the suggestion. Then changeitems={products}toitems={["a"]}and see howpand the error change:Tfollows the data. - 2
Constrain T further
In the Select example, try
<Select options={[true, false]} ... />.booleandoesn't satisfystring | number, so it is an error. Widen the constraint toT extends string | number | boolean(in bothSelectPropsandSelect) and see what stops compiling:key={option}fails, because a React key must be a string or number. - 3
Switch the element
In the Text example, change
as="a" href="/docs"toas="button" href="/docs". A button has nohref, so what do you expect? Then tryas="input"with children: an input cannot have children, but the types do not complain, becausechildrenis declared for every element's props. What does that tell you about the limits?
Code & diagrams
Nobody wrote T. For the first list it is Product, for the second string. Try p.nme in renderItem and read the suggestion.
Preview
Console
Nothing logged yet.Expected console output
<ul><li>Pen $12.50</li><li>Notebook $80.00</li></ul><ul><li>A</li><li>B</li></ul><p>No products</p>The first Select infers T = "S" | "M" | "L" from the as const array, so onChange={setSize} fits exactly. The second infers T = number. Because find() can return undefined, the component checks before calling onChange.
Preview
Console
Nothing logged yet.Expected console output
M x 1With as="a" the props of an anchor are allowed, so href is fine but href={42} is a number where a string is needed. With as="button", type and onClick are typed as a button's. Add href to the plain span and read the error.
Preview
Console
Nothing logged yet.Expected console output
<p><span>plain span</span><a href="/docs">a link</a><button type="button">a button</button><a href="42">bad href</a></p>Field accepts type, required, min and an onChange whose event is typed, all from the input's own props. It forbids id, so the third Field is an error. The script reads the DOM to show the props were forwarded and the label is linked to the generated id.
Preview
Console
Nothing logged yet.Expected console output
3 email 0 true
trueBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
A typo inside renderItem
In the List example, change p.name to p.nme inside renderItem.
Break #2
Mix up the Select's type
In the Select example, change the second Select's onChange={(next) => setQty(next)} to onChange={setSize}.
Break #3
Ask a span for an href
In the Text example, replace the last <Text as="a" href={42}>bad href</Text> line with <Text as="span" href="/docs">span with href</Text>.
Myth vs fact
Myth
I must write <List<Product> ...> in JSX.
Fact
Almost never. TypeScript infers the type argument from the props. Write it only when inference has nothing to go on (an empty array).
Myth
A generic component is the same as using any.
Fact
any turns checking off. A type parameter keeps the exact type flowing from items into every callback.
Myth
Polymorphic components give perfect safety.
Fact
The call sites are checked, but the component's body needs a loose ElementType cast. It is safe at the boundary and loose inside.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
TypeScript infers
Tfrom candidates in covariant positions (items: T[]) and contravariant positions (callback parameters). A callback likegetKey={(p) => p.id}can't help inference, because its parameter is typed *from*T; that is whyitemsmust be passed and why the order of props doesn't matter. If two props disagree (itemsof products,valueof a string), you get an error naming one of them. - ▸
Use
NoInfer<T>(Topic 7.6) when one prop should only be *checked* againstT, not contribute to it. For aSelect,defaultValue: NoInfer<T>keepsoptionsas the only source forT. - ▸
Generic components lose their type argument through wrappers like
React.memoandforwardRef: both return a non-generic type. The usual fix is a cast of the wrapped component back to its generic function type, or avoid memo on generic components. In React 19refis a normal prop (Topic 14.6), so the forwardRef problem largely disappears. - ▸
Deeply generic JSX is expensive to check. A long intersection with
Omit<ComponentPropsWithoutRef<C>, ...>is recomputed for each use ofC. If error messages become long (as above) or the editor gets slow, simplify: a union of the few elements you really support (as?: "a" | "button" | "span") often beats full generality.
Remember this
- 1
The problem this solves: a list, table or select component should work for any kind of data, but if its callbacks receive
anyorunknownyou lose all checking inside them. A type parameter keeps the exact item type flowing through. You know generics from Phase 7: a function or type with a type parameter, a placeholder likeT, filled in by each use. A component is a function, so it can have one too:function List<T>(props: ListProps<T>). Thenitems: T[]andrenderItem: (item: T) => ReactNodeare tied together by the sameT. Pass products, andrenderItem's argument is aProduct. You almost never write<List<Product> ...>: TypeScript infersTfrom theitemsprop. - 2
Why not
items: any[]orunknown[]? Withany,renderItem={(p) => p.nme}has no error and the typo reaches the page. Withunknown[],pisunknownand you can't read anything without casts. The type parameter is the only way to get both flexibility and checking. This is the same idea asArray<T>'smap, applied to the props of a component. - 3
Constrain the parameter when the component needs something from every item.
Select<T extends string | number>says options must be strings or numbers, which lets the component callString(option)and use it as a Reactkey. A constraint (Topic 7.3) is a promise the caller must keep; in return, the component's body may use that capability. Constrain as little as the body needs. - 4
In a
.tsxfile there is one gotcha: the arrow-function formconst List = <T>(props: ...) => ...is parsed as the start of a JSX tag, and fails. Write<T,>(with a comma) or<T extends unknown>for arrow functions, or just use afunctiondeclaration, which has no such problem. - 5
A polymorphic component has an
asprop that picks the element it renders:<Text as="a" href="/docs">,<Text as="button" type="button">. The hard part is typing the *other* props: ifas="a", thenhrefmust be allowed; ifas="span", it must be an error. The recipe: a type parameterC extends ElementTypefor the element, andComponentPropsWithoutRef<C>for its props, with your own props removed byOmit.ElementTypeis React's type for "anything that can be a tag: an HTML tag name or a component". - 6
Forwarding props means a wrapper component accepts everything the inner element accepts and passes it through. For one fixed element it is simple:
Omit<ComponentPropsWithoutRef<"input">, "id"> & { label: string }, then<input {...inputProps} />.ComponentPropsWithoutRefis the safer starting point thanComponentPropsbecause it dropsref, which older React handles separately (Topic 14.6 shows React 19).Omitalso lets you forbid props the wrapper sets itself, like theidabove. - 7
Honest limits. In the
aspattern the component body is the loosely checked part: TypeScript cannot check<Tag {...rest}>whenTagis a generic element, so the body assigns the tag to a variable of the wide typeElementType(const Tag: ElementType = as ?? "span") andrestis not checked against it. The safety is at the boundary, where callers use the component, and that is where it matters. Don't spread generics through your whole codebase; use a polymorphic component for a handful of layout primitives (Box,Text,Stack) and keep the rest plain.
Explain it without notes
Why is a generic List<T> better than List with items: any[]?
How does TypeScript infer T in <List items={products} renderItem={(p) => ...} />?
How do you type a polymorphic as prop so that the other props follow the element?
Why ComponentPropsWithoutRef and not ComponentProps when wrapping an element?
Practice
Write a generic Table<T> with props rows: T[], columns: { header: string; cell: (row: T) => ReactNode }[] and getKey. Use it with users and show a typo in cell failing.
Write a Stack component that renders a div by default but accepts as="ul" or as="section" and forwards that element's props, with a gap: number prop of its own.
Trade-offs
- ↔
Generic components are the best way to keep types exact in reusable UI, but each type parameter adds to what the next reader must understand. Use them for containers (lists, tables, selects), not for every leaf component.
- ↔
A fully polymorphic component is convenient, yet its error messages are long and its body needs a cast. A smaller union of allowed elements, or two separate components, is often easier to read and faster to type-check.
Done when you can
I can write
function List<T>(props: ListProps<T>)and explain howTis inferred.I can constrain a type parameter and say what the body gains from it.
I can avoid the
<T>parsing problem in.tsxarrow functions.I can type an
asprop withElementTypeandComponentPropsWithoutRef.I can forward an element's props and
Omitthe ones I set myself.I can read the long polymorphic error by looking for the
Property ... does not existline.