Command Palette

Search for a command to run...

Hectal
PHASE 12Intermediate ~30 min· topic 6 of 6

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: T must 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 routes is "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.

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>;
}

<List items={users} getKey={(u) => u.id} renderItem={(u) => u.title} />
// error TS2339: Property 'title' does not exist on type 'User'.
Make a component genericdiagram
Rendering diagram…

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.

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

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

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

routes.tswhole filetsx
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. 1

    Add a plan

    In the first example add "team" to PLANS and give Ravi plan: "team". Predict: what does tsc say about Ravi's object? (Type '"team"' is not assignable to type '"free" | "pro"'., because User.plan doesn't include it.) Update User too, then predict the logs when team is picked.

  2. 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 props tsc now allows (type, onClick, disabled: everything a <button> takes).

  3. 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 would tsc now reject? (const unread: number = NAV.inbox.badge; because badge is number | undefined.)

Code & diagrams

A generic List and a generic Select New tab

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

plan: all -> Asha, Ravi
plan: free -> Ravi
A polymorphic Text and discriminated union props New tab

One `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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

<p>
<h2> id=title
<a> href=#docs
<label> for=email
<span> href=#nope
<a> href=#help
<button>
saved
satisfies keeps exact keys for a nav config New tab

With `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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

page: home | unread: 3
page: settings | unread: 3
🏠 Home | 📥 Inbox (3) | ⚙️ Settings

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

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.

terminal
$ tsc and Preview
── what you'll see ──
tsc: no errors. The page: every list item is empty, and no warning appears anywhere.

Break #2

Destructure union props before narrowing

In Action, write function Action({ kind, href, onClick, label }: ActionProps).

terminal
$ npx tsc --noEmit
── what you'll see ──
error TS2339: Property 'href' does not exist on type '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.

  • ▸

    satisfies arrived in TypeScript 4.9. A common pattern for constants is as const satisfies Record<string, Route>: as const makes the values literal and read-only, and satisfies checks the shape, so keyof typeof routes and each path are exact literal types usable in other types (like a typed navigate("/about")).

  • ▸

    TypeScript can't make a generic component's type parameter survive memo or higher-order wrappers that use ComponentType<P>: memo(List) returns a non-generic component (T becomes unknown). 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's ref, so ref can flow through ...rest like any other prop. Before React 19 this required forwardRef, which erased the generic, and libraries used complicated PolymorphicComponentPropsWithRef helpers.

  • ▸

    Discriminated union props can also use ?: never to 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. 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 infers T = User from items, so u in renderItem is a User and u.title is an error (Property 'title' does not exist on type 'User'.). Without generics you'd type items as unknown[] or any[] and lose that safety. You can also pass T explicitly in JSX: <List<User> items={...} />. In a .tsx file a generic arrow function needs <T,> (the comma tells the parser it's not a tag); a function declaration doesn't.

  2. 2

    Constraints say what a type parameter must be: <T extends string> means "any string type", so a Select<T extends string> can put T in <option value>, and with options={["S", "M", "L"] as const} the value and onChange are typed as "S" | "M" | "L", not just string. The one place types meet the DOM, e.currentTarget.value, is a plain string, so the component casts it (as T); that's safe because only your options can be selected. (typeof PLANS)[number] turns a const array into the union of its items, so the list of options and the type come from one source.

  3. 3

    **Polymorphic as prop.** Design systems need a Text or Box that 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 infers E from as, 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 an asChild pattern or accept the complexity.

  4. 4

    Discriminated union props. When some props only make sense together, model the component's props as a union with a kind (or variant) 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 no onClick and needs href. Inside, check props.kind to narrow (don't destructure first: destructuring before the check loses the link between kind and the other props in older TypeScript versions, and reading href before narrowing is an error). This is the props version of the reducer actions in Topic 12.3: make impossible states impossible to write.

  5. 5

    **satisfies.** An annotation (const routes: Record<string, Route> = {...}) checks the value but then forgets its details: TypeScript only knows "some string keys", so routes.hme is 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 routes is "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 T also makes everything read-only literals.

  6. 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-error lines in a test file, so a future change that accidentally makes invalid usage compile is caught by tsc.

Explain it without notes

01

How does a generic List<T> component work, and what does it give callers compared with any[]?

02

How is a polymorphic as prop typed, and what does TypeScript check?

03

When would you model a component's props as a discriminated union?

04

What is the difference between annotating a constant and using satisfies?

Practice

01

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.

02

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.

  • ↔

    satisfies keeps precise types, which is great for reading config, but those precise types also spread: a function that takes typeof NAV is 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 how T is inferred.

  • I can constrain a type parameter (T extends string) and derive a union from a const array.

  • I can type a polymorphic as prop with ElementType and ComponentPropsWithoutRef.

  • I can model mutually exclusive props as a discriminated union and narrow on the discriminant.

  • I can use satisfies to check config while keeping its exact keys.