Command Palette

Search for a command to run...

Hectal
PHASE 14Intermediate ~25 min· topic 5 of 6

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 T that 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 T from the props you pass, so you don't write it.
Polymorphic component
A component that can render as different elements, chosen with an as prop.
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.

List.tsxwhole filetsx
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.

arrow.tsxwhole filetsx
// 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.

Text.tsxwhole filetsx
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>;
}
The as propdiagram
Rendering diagram…

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. 1

    Typo inside the callback

    In the List example, change p.name to p.nme. Predict the message and the suggestion. Then change items={products} to items={["a"]} and see how p and the error change: T follows the data.

  2. 2

    Constrain T further

    In the Select example, try <Select options={[true, false]} ... />. boolean doesn't satisfy string | number, so it is an error. Widen the constraint to T extends string | number | boolean (in both SelectProps and Select) and see what stops compiling: key={option} fails, because a React key must be a string or number.

  3. 3

    Switch the element

    In the Text example, change as="a" href="/docs" to as="button" href="/docs". A button has no href, so what do you expect? Then try as="input" with children: an input cannot have children, but the types do not complain, because children is declared for every element's props. What does that tell you about the limits?

Code & diagrams

A generic List<T>: the callbacks know the item type

Nobody wrote T. For the first list it is Product, for the second string. Try p.nme in renderItem and read the suggestion.

Preview

Press Run to see the page.

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>
A generic Select<T>: onChange gives T, not a string

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

M x 1
A polymorphic Text with an as prop

With 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

Press Run to see the page.

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>
Forwarding the props of an input

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

3 email 0 true
true

Break 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.

terminal
$ Types panel
── what you'll see ──
index.tsx(34,30): error TS2551: Property 'nme' does not exist on type 'Product'. Did you mean 'name'?

Break #2

Mix up the Select's type

In the Select example, change the second Select's onChange={(next) => setQty(next)} to onChange={setSize}.

terminal
$ Types panel
── what you'll see ──
index.tsx(38,47): error TS2322: Type 'Dispatch<SetStateAction<"L" | "M" | "S">>' is not assignable to type '(value: number) => void'.
Types of parameters 'value' and 'value' are incompatible.
Type 'number' is not assignable to type 'SetStateAction<"L" | "M" | "S">'.

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>.

terminal
$ Types panel
── what you'll see ──
index.tsx(20,23): error TS2322: Type '{ as: "span"; href: string; children: string; }' is not assignable to type 'IntrinsicAttributes & { as?: "span"; children: ReactNode; } & Omit<Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref">, "as" | "children">'.
Property 'href' does not exist on type 'IntrinsicAttributes & { as?: "span"; children: ReactNode; } & Omit<Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref">, "as" | "children">'.

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 T from candidates in covariant positions (items: T[]) and contravariant positions (callback parameters). A callback like getKey={(p) => p.id} can't help inference, because its parameter is typed *from* T; that is why items must be passed and why the order of props doesn't matter. If two props disagree (items of products, value of a string), you get an error naming one of them.

  • ▸

    Use NoInfer<T> (Topic 7.6) when one prop should only be *checked* against T, not contribute to it. For a Select, defaultValue: NoInfer<T> keeps options as the only source for T.

  • ▸

    Generic components lose their type argument through wrappers like React.memo and forwardRef: 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 19 ref is 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 of C. 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. 1

    The problem this solves: a list, table or select component should work for any kind of data, but if its callbacks receive any or unknown you 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 like T, filled in by each use. A component is a function, so it can have one too: function List<T>(props: ListProps<T>). Then items: T[] and renderItem: (item: T) => ReactNode are tied together by the same T. Pass products, and renderItem's argument is a Product. You almost never write <List<Product> ...>: TypeScript infers T from the items prop.

  2. 2

    Why not items: any[] or unknown[]? With any, renderItem={(p) => p.nme} has no error and the typo reaches the page. With unknown[], p is unknown and 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 as Array<T>'s map, applied to the props of a component.

  3. 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 call String(option) and use it as a React key. 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. 4

    In a .tsx file there is one gotcha: the arrow-function form const 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 a function declaration, which has no such problem.

  5. 5

    A polymorphic component has an as prop that picks the element it renders: <Text as="a" href="/docs">, <Text as="button" type="button">. The hard part is typing the *other* props: if as="a", then href must be allowed; if as="span", it must be an error. The recipe: a type parameter C extends ElementType for the element, and ComponentPropsWithoutRef<C> for its props, with your own props removed by Omit. ElementType is React's type for "anything that can be a tag: an HTML tag name or a component".

  6. 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} />. ComponentPropsWithoutRef is the safer starting point than ComponentProps because it drops ref, which older React handles separately (Topic 14.6 shows React 19). Omit also lets you forbid props the wrapper sets itself, like the id above.

  7. 7

    Honest limits. In the as pattern the component body is the loosely checked part: TypeScript cannot check <Tag {...rest}> when Tag is a generic element, so the body assigns the tag to a variable of the wide type ElementType (const Tag: ElementType = as ?? "span") and rest is 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

01

Why is a generic List<T> better than List with items: any[]?

02

How does TypeScript infer T in <List items={products} renderItem={(p) => ...} />?

03

How do you type a polymorphic as prop so that the other props follow the element?

04

Why ComponentPropsWithoutRef and not ComponentProps when wrapping an element?

Practice

01

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.

02

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 how T is inferred.

  • I can constrain a type parameter and say what the body gains from it.

  • I can avoid the <T> parsing problem in .tsx arrow functions.

  • I can type an as prop with ElementType and ComponentPropsWithoutRef.

  • I can forward an element's props and Omit the ones I set myself.

  • I can read the long polymorphic error by looking for the Property ... does not exist line.