Topic 12.6
Generic and Polymorphic Components
In one line
Generic components (List<T>, Select<T>) keep the caller's item type all the way into callbacks; a polymorphic as prop lets one component render any element with that element's props; discriminated union props make invalid prop combinations impossible; and satisfies checks a value against a type without losing its exact shape.
Think of it like this
A label maker. It doesn't care whether you label jam jars, school books or cables: you feed in any kind of item, and it prints a label that fits that item. A generic component is the label maker: List works for users, songs or tags, and inside each use it knows exactly which kind of item it holds. A polymorphic component is a stamp that can print on paper, cloth or wood, and adjusts to the material you pick.
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, like
List<T>, so it keeps the caller's item type. - Constraint
- A rule for a type parameter, written
T extends string:Tmust be some kind of string. - ElementType
- React's type for anything usable as a JSX tag: a tag name like
"a"or a component. - Polymorphic component
- A component whose rendered element is chosen by a prop (usually
as), with props that follow that element. - Discriminant
- The shared property (
kind,type) whose literal value tells the union's members apart. - satisfies
- An operator that checks a value against a type but keeps the value's own precise type.
- keyof typeof
- Gets the union of a value's keys as a type:
keyof typeof routesis"home" | "about". - Indexed access type
- Reading a type out of another type:
(typeof PLANS)[number]is the union of the array's items.
Step by step
01Make a component generic
Add <T> after the name and use T in the props. The caller's items decide what T is, and the callbacks receive that type.
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>;
}
<List items={users} getKey={(u) => u.id} renderItem={(u) => u.title} />
// error TS2339: Property 'title' does not exist on type 'User'.02Constrain the type parameter
T extends string lets the component use T as an option value while keeping the exact literal union for the caller.
type SelectProps<T extends string> = {
options: readonly T[];
value: T;
onChange: (value: T) => void;
};
const SIZES = ["S", "M", "L"] as const;
type Size = (typeof SIZES)[number]; // "S" | "M" | "L"
<Select options={SIZES} value={size} onChange={setSize} /> // T = "S" | "M" | "L"03Build a polymorphic component
as decides the element; ComponentPropsWithoutRef<E> supplies that element's props. Take as and children out of the borrowed props so yours win.
type TextProps<E extends ElementType> = {
as?: E;
children: ReactNode;
} & Omit<ComponentPropsWithoutRef<E>, "as" | "children">;
function Text<E extends ElementType = "p">({ as, children, ...rest }: TextProps<E>) {
const Tag = as ?? "p";
return <Tag {...rest}>{children}</Tag>;
}
<Text as="a" href="/docs">Docs</Text> // OK
<Text as="span" href="/docs">Docs</Text>
// error TS2322: Type '{ children: string; as: "span"; href: string; }' is not assignable to type 'IntrinsicAttributes & { as?: "span" | undefined; children: ReactNode; } & Omit<Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref">, "as" | "children">'.
// Property 'href' does not exist on type 'IntrinsicAttributes & { as?: "span" | undefined; children: ReactNode; } & Omit<Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref">, "as" | "children">'.04Model 'either this or that' props
A union of prop shapes with a kind stops callers from mixing them. Read the other props only after checking kind.
type ActionProps =
| { kind: "link"; href: string; label: string }
| { kind: "button"; onClick: () => void; label: string };
function Action(props: ActionProps) {
if (props.kind === "link") return <a href={props.href}>{props.label}</a>;
return <button onClick={props.onClick}>{props.label}</button>;
}
<Action kind="link" onClick={() => {}} label="Go" />
// error TS2322: Type '{ kind: "link"; onClick: () => void; label: string; }' is not assignable to type 'IntrinsicAttributes & ActionProps'.
// Property 'onClick' does not exist on type 'IntrinsicAttributes & { kind: "link"; href: string; label: string; }'.05Check config with satisfies
satisfies checks every entry and keeps the exact keys, so both writing and reading typos are caught. The annotation version catches neither of the reading typos.
type Route = { path: string; title: string };
// 1. Annotation: checked, but the specific keys are forgotten.
const annotated: Record<string, Route> = {
home: { path: "/", title: "Home" },
};
annotated.hme.path; // no error, crashes at run time
// 2. satisfies: checked AND the exact keys are kept.
const routes = {
home: { path: "/", title: "Home" },
about: { path: "/about", titel: "About" },
// error TS2561: Object literal may only specify known properties, but 'titel' does not exist in type 'Route'. Did you mean to write 'title'?
} satisfies Record<string, Route>;
routes.hme.path;
// error TS2551: Property 'hme' does not exist on type '{ home: { path: string; title: string; }; about: { path: string; titel: string; }; }'. Did you mean 'home'?
type RouteName = keyof typeof routes; // "home" | "about"
const r: RouteName = "contact";
// error TS2322: Type '"contact"' is not assignable to type '"home" | "about"'.Try it yourself
- 1
Add a plan
In the first example add
"team"toPLANSand give Raviplan: "team". Predict: what doestscsay about Ravi's object? (Type '"team"' is not assignable to type '"free" | "pro"'., becauseUser.plandoesn't include it.) UpdateUsertoo, then predict the logs when team is picked. - 2
Render Text as a button
Add
<Text as="button" type="button" onClick={() => console.log("text clicked")}>Click</Text>. Predict the new log line from the loop (<button>) and decide which propstscnow allows (type,onClick,disabled: everything a<button>takes). - 3
Swap satisfies for an annotation
In the nav example change
} satisfies Record<string, NavItem>;to an annotation on the variable:const NAV: Record<string, NavItem> = {. The preview runs the same. Which lines wouldtscnow reject? (const unread: number = NAV.inbox.badge;becausebadgeisnumber | undefined.)
Code & diagrams
The script picks **free** in the select. `onChange={setPlan}` type-checks because `Select` inferred `T = "all" | "free" | "pro"` from `PLANS`, which exactly matches the state's setter. The same `List` renders `User`s and `string`s, and each use knows its own item type.
Preview
Console
Nothing logged yet.Expected console output
plan: all -> Asha, Ravi
plan: free -> RaviOne `Text` component rendered six different ways, and each use was checked against its own element: `htmlFor` on a `label`, `href` on an `a`. The `span` with `href` is a type error (types are stripped here, so it still renders the attribute). `Action` can't be given both `href` and `onClick`, or neither.
Preview
Console
Nothing logged yet.Expected console output
<p>
<h2> id=title
<a> href=#docs
<label> for=email
<span> href=#nope
<a> href=#help
<button>
savedWith `const NAV: Record<string, NavItem>` instead, `Page` would be just `string` (so `setPage("inboxx")` would compile) and `NAV.inbox.badge` would be `number | undefined`. `satisfies` gives the check **and** the precise type. `Object.keys` always returns `string[]` in TypeScript (objects may have extra keys at run time), hence the `as Page[]`.
Preview
Console
Nothing logged yet.Expected console output
page: home | unread: 3
page: settings | unread: 3
🏠 Home | 📥 Inbox (3) | ⚙️ SettingsBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Type a list's items as any
Write function List({ items, renderItem }: { items: any[]; renderItem: (item: any) => ReactNode }) and use renderItem={(u) => u.title} on users that only have name.
Break #2
Destructure union props before narrowing
In Action, write function Action({ kind, href, onClick, label }: ActionProps).
Myth vs fact
Myth
Generic components are only for library authors.
Fact
Any reusable list, table, select or combobox in an app benefits: callbacks get the right item type and callers can't mix up item shapes. Inside one feature, plain props are fine.
Myth
satisfies is the same as as.
Fact
Opposites: as tells the checker to trust you and stops checking; satisfies makes the checker verify the value and then keeps the value's precise type.
Myth
Polymorphic components are free.
Fact
They give great flexibility, but the types are complex, errors are long, and refs need extra work. Many design systems use a simpler asChild approach or a few fixed variants instead.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
satisfiesarrived in TypeScript 4.9. A common pattern for constants isas const satisfies Record<string, Route>:as constmakes the values literal and read-only, andsatisfieschecks the shape, sokeyof typeof routesand eachpathare exact literal types usable in other types (like a typednavigate("/about")). - ▸
TypeScript can't make a generic component's type parameter survive
memoor higher-order wrappers that useComponentType<P>:memo(List)returns a non-generic component (Tbecomesunknown). The usual fix is a cast:export const MemoList = memo(List) as typeof List;. - ▸
For polymorphic components that forward refs in React 19,
ComponentPropsWithRef<E>includes the element'sref, sorefcan flow through...restlike any other prop. Before React 19 this requiredforwardRef, which erased the generic, and libraries used complicatedPolymorphicComponentPropsWithRefhelpers. - ▸
Discriminated union props can also use
?: neverto forbid a prop explicitly:{ kind: "link"; href: string; onClick?: never }. That's useful when the members don't have a single discriminant, for example{ value: string; onChange: ... } | { defaultValue?: string; value?: never }for controlled versus uncontrolled inputs (Topic 5.2).
Remember this
- 1
Generic components. A component is a function, so it can have a type parameter like a generic hook (Topic 12.5):
function List<T>({ items, renderItem }: ListProps<T>). When you write<List items={users} renderItem={(u) => u.name} />, TypeScript infersT = Userfromitems, souinrenderItemis aUserandu.titleis an error (Property 'title' does not exist on type 'User'.). Without generics you'd typeitemsasunknown[]orany[]and lose that safety. You can also passTexplicitly in JSX:<List<User> items={...} />. In a.tsxfile a generic arrow function needs<T,>(the comma tells the parser it's not a tag); afunctiondeclaration doesn't. - 2
Constraints say what a type parameter must be:
<T extends string>means "any string type", so aSelect<T extends string>can putTin<option value>, and withoptions={["S", "M", "L"] as const}thevalueandonChangeare typed as"S" | "M" | "L", not juststring. The one place types meet the DOM,e.currentTarget.value, is a plainstring, so the component casts it (as T); that's safe because only your options can be selected.(typeof PLANS)[number]turns aconstarray into the union of its items, so the list of options and the type come from one source. - 3
**Polymorphic
asprop.** Design systems need aTextorBoxthat renders as<p>,<h2>,<a>or<label>depending on context, for correct semantics and accessibility. The type:type TextProps<E extends ElementType> = { as?: E; children: ReactNode } & Omit<ComponentPropsWithoutRef<E>, "as" | "children">. **ElementType** means "a tag name like"a"or a component". TypeScript infersEfromas, then requires exactly that element's props:<Text as="a" href="/docs">is fine,<Text as="span" href="/docs">is an error. Inside,const Tag = as ?? "p"and<Tag {...rest}>render it (a capitalised variable can be used as a JSX tag). Supporting refs too makes the types much harder; many libraries choose anasChildpattern or accept the complexity. - 4
Discriminated union props. When some props only make sense together, model the component's props as a union with a
kind(orvariant) discriminant:{ kind: "link"; href: string } | { kind: "button"; onClick: () => void }. Callers must pick one shape:<Action kind="link" onClick={...} />is an error, because a link has noonClickand needshref. Inside, checkprops.kindto narrow (don't destructure first: destructuring before the check loses the link betweenkindand the other props in older TypeScript versions, and readinghrefbefore narrowing is an error). This is the props version of the reducer actions in Topic 12.3: make impossible states impossible to write. - 5
**
satisfies.** An annotation (const routes: Record<string, Route> = {...}) checks the value but then forgets its details: TypeScript only knows "some string keys", soroutes.hmeis not caught.satisfies(TypeScript 4.9+) checks the value against a type and keeps the value's own, more precise type:const routes = {...} satisfies Record<string, Route>. Now a typo inside (titel) is an error, a typo when reading (routes.hme) is an error,keyof typeof routesis"home" | "about", and optional fields that are present are known to be present. It's ideal for config objects: route tables, nav items, theme tokens, column definitions.as const satisfies Talso makes everything read-only literals. - 6
Choosing. Reach for these tools in shared components (design systems, tables, forms) where many callers benefit; inside one feature, plain props are clearer. Each technique has a cost in error-message length: polymorphic components produce the longest messages in this phase. Test your component types with a few
// @ts-expect-errorlines in a test file, so a future change that accidentally makes invalid usage compile is caught bytsc.
Explain it without notes
How does a generic List<T> component work, and what does it give callers compared with any[]?
How is a polymorphic as prop typed, and what does TypeScript check?
When would you model a component's props as a discriminated union?
What is the difference between annotating a constant and using satisfies?
Practice
Write a generic Table<T> with props rows: T[], columns: { header: string; cell: (row: T) => ReactNode }[] and getKey, and render it for a list of products.
Write a Notice component whose props are either { kind: "dismissible"; onDismiss: () => void; text: string } or { kind: "static"; text: string }, and render one of each.
Trade-offs
- ↔
Generic and polymorphic components remove whole classes of bugs for callers, but their own code and error messages are harder to read. Use them for widely shared building blocks, not for one-off components.
- ↔
satisfieskeeps precise types, which is great for reading config, but those precise types also spread: a function that takestypeof NAVis tied to its exact keys. Export a named wider type for APIs that should accept any config.
Done when you can
I can write a generic component like
List<T>and explain howTis inferred.I can constrain a type parameter (
T extends string) and derive a union from aconstarray.I can type a polymorphic
asprop withElementTypeandComponentPropsWithoutRef.I can model mutually exclusive props as a discriminated union and narrow on the discriminant.
I can use
satisfiesto check config while keeping its exact keys.