The typed props contract
How to write a React component's props as a typed contract, the foundation this chapter on components and composition builds on.
Here are two ways to write the same Save button. Notice which one you’d rather inherit.
<Button isPrimary isLarge withIcon iconName="save" iconPosition="left" loading={false}> Save</Button>Six independent props, none constraining the others. Nothing stops you setting isPrimary and isSecondary both true, or pairing iconName="save" with withIcon={false}, and the only way to learn what’s legal is to read the source. The list only grows: every design tweak adds another boolean to remember.
<Button variant="primary" size="lg"> Save</Button>Two named axes, each a fixed set of mutually-exclusive options. Appearance lives on variant, scale on size, and primary and secondary can’t both be set because they’re values of one prop. Nothing to misconfigure, and the editor autocompletes the allowed values.
The fault in the first version isn’t any single prop, it’s the shape. The second is a contract: a small named surface that states exactly what it accepts and rejects everything else before the code runs.
Up to now you’ve styled tags you owned at the call site, a <button> or a <div> dressed with Tailwind. Here you start writing the thing other code calls, where the props are that contract. This lesson covers the form an experienced developer reaches for and the four disciplines that keep it small: a typed props contract, variant unions instead of boolean piles, native-attribute inheritance, and an always-open className.
You already have every piece this rests on. By the end you’ll have written the canonical <Button> that the rest of the chapter extends, the same component the next lessons layer children, Slot, and cva onto.
A component is a typed function of props
Section titled “A component is a typed function of props”A React component is a function that takes props and returns JSX. The 2026 form is an arrow function bound to const, with a PascalCase name, props destructured in the parameter, and no return type, since inference supplies it.
At its simplest, that’s one prop, typed inline:
const Greeting = ({ name }: { name: string }) => <p>Hello, {name}</p>;You won’t usually inline the type, though. Once a component has more than one prop, pull the shape into a named type called Props:
type Props = { label: string; tone: 'neutral' | 'success' | 'warning';};
const Badge = ({ label, tone }: Props) => ( <span className={cn('rounded-full px-2 py-0.5 text-xs', toneClasses[tone])}> {label} </span>);An arrow function bound to const, named in PascalCase. The casing changes behavior, not just style: write badge in lowercase and JSX treats <badge> as a literal DOM tag, silently dropping every prop you pass it. PascalCase is how React knows this is your function and not an HTML element.
type Props = { label: string; tone: 'neutral' | 'success' | 'warning';};
const Badge = ({ label, tone }: Props) => ( <span className={cn('rounded-full px-2 py-0.5 text-xs', toneClasses[tone])}> {label} </span>);Props arrive as one object. Destructure the names you want in the parameter and annotate it with your Props type. This is the form you’ll write for every component in the course.
type Props = { label: string; tone: 'neutral' | 'success' | 'warning';};
const Badge = ({ label, tone }: Props) => ( <span className={cn('rounded-full px-2 py-0.5 text-xs', toneClasses[tone])}> {label} </span>);Use type Props, never interface: the same call you made when weighing type against interface, where interface earns its place only for declaration merging, which props never do. And Badge has no return type. Inference already knows it returns a JSX.Element , so annotating it only adds noise.
Typing props: strings, unions, and function signatures
Section titled “Typing props: strings, unions, and function signatures”Four shapes cover almost every prop you’ll type.
type Props = { title: string; count: number; variant: 'primary' | 'secondary' | 'destructive'; icon?: string; onClick: (event: MouseEvent<HTMLButtonElement>) => void;};Three are familiar on sight: a string, a number, and an optional prop marked with ?. The other two carry the new pieces.
The union of string literals, variant: 'primary' | 'secondary' | 'destructive', is the workhorse of component props. It says the prop is exactly one of these three strings, so the editor autocompletes them and rejects anything else. Reach for it any time a prop has a small, fixed set of legal values.
The function prop is typed by its signature. onClick: (event: MouseEvent<HTMLButtonElement>) => void says the component hands its caller a click event and expects nothing back. Note that MouseEvent is React’s synthetic event type, imported from react, not the MouseEvent on the DOM global. The generic argument matters: MouseEvent<HTMLButtonElement> types event.currentTarget as a button element, so reading event.currentTarget.disabled type-checks. Name the wrong element and that access stops type-checking.
One habit to carry onto this surface: never type a prop any. Reach for the specific shape, or for unknown and narrow at the boundary. Typing a prop any throws away the entire contract for that field.
Defaulting props in the destructure
Section titled “Defaulting props in the destructure”Default values go in the parameter destructure, right next to the prop they apply to.
const Button = ({ variant = 'primary', size = 'md' }: Props) => { // ...};defaultProps, the old object you’d hang on a component to supply defaults, is gone: React 19 removed it for function components, so the destructure is the only form now.
Resist defaulting with a falsy check in the body, like const v = variant || 'primary'. That’s the || trap on a new surface: || fires on any falsy value, so a legitimate falsy prop gets overridden, and the default ends up scattered away from the signature. The destructure default fires only on undefined. Pass nothing or size={undefined} and you get 'md'; pass any real value and it stands. That undefined-only behavior is why the default sits cleanly beside the prop name.
Collapsing boolean flags into a variant union
Section titled “Collapsing boolean flags into a variant union”Take isPrimary, isDestructive, isGhost, the kind of booleans the Coffin button was built from, and collapse them into a single prop:
type Props = { isPrimary?: boolean; isDestructive?: boolean; isGhost?: boolean;};Eight states, three of them real. Three booleans encode 2³ = 8 combinations, and most are nonsense, like isPrimary and isGhost both true, or all three false. The type permits every one of them.
type Props = { variant?: 'primary' | 'destructive' | 'ghost';};Three states, exactly the three that are real. The variants are mutually exclusive by construction, since you cannot set two at once, and there’s nothing illegal left to represent.
This is a judgment call you’ll make over and over, so it’s worth walking the reasoning.
Mutually exclusive by construction. The appearance is one value, so two appearances at once isn’t a bug you have to remember to avoid, it’s a state the type can’t even express. This is the make-illegal-states-unrepresentable principle you’ve seen before, now living on a props boundary.
Exhaustive. Because the variant is a closed union, a switch or lookup over it can be checked for completeness: add a 'link' variant later and TypeScript surfaces every place that has to handle it. Three independent booleans give you no such net, since nothing connects them for the compiler to check.
One axis, one prop. The same logic applies to scale: size: 'sm' | 'md' | 'lg' over isSmall and isLarge. Whenever a set of options is mutually exclusive, like one appearance, one size, or one alignment, they belong on one union prop.
These unions are the input that class-variance-authority consumes later in this chapter: the variant and size props you define here become the keys cva maps to class strings, so keep the shape clean.
Once this clicks, there’s an easy overcorrection: turning everything into a union. A union is for one axis with mutually-exclusive options. A boolean is for an independent on/off flag that has nothing to do with any other prop, like disabled, loading, or fullWidth. Forcing those into a union is the same mistake in the other direction. The next exercise trains that judgment.
A union is for one axis with mutually-exclusive options; a boolean is for an independent on/off flag. Drag each item into the bucket it belongs to, then press Check.
variantsizetonealign (start | center | end)disabledloadingfullWidthInheriting native attributes with ComponentProps
Section titled “Inheriting native attributes with ComponentProps”Your <Button> wraps a native <button>, and a native <button> accepts a lot: disabled, type, aria-label, onClick, form, name, every standard attribute and every event handler. If the consumer can’t pass those through to the real element underneath, your <Button> is a downgrade from the raw tag. Hand-typing each one in Props is endless and always one attribute behind.
The fix is one type plus one spread. First, reach for the React utility type ComponentProps , which pulls in everything the JSX <button> accepts, and intersect it with the props your component adds:
type Props = ComponentProps<'button'> & { variant?: 'primary' | 'destructive' | 'ghost'; size?: 'sm' | 'md' | 'lg';};You never list the native props: the alias is the list, and it stays correct as the platform evolves. In React 19 it includes ref too.
Then, in the body, destructure the props your component consumes and spread the rest straight onto the element:
type Props = ComponentProps<'button'> & { variant?: 'primary' | 'destructive' | 'ghost'; size?: 'sm' | 'md' | 'lg';};
const Button = ({ variant = 'primary', size = 'md', className, ...rest}: Props) => ( <button className={cn(buttonClasses({ variant, size }), className)} {...rest} />);The intersection pulls in every native button prop and adds your two, so the component accepts everything a real <button> does plus variant and size.
type Props = ComponentProps<'button'> & { variant?: 'primary' | 'destructive' | 'ghost'; size?: 'sm' | 'md' | 'lg';};
const Button = ({ variant = 'primary', size = 'md', className, ...rest}: Props) => ( <button className={cn(buttonClasses({ variant, size }), className)} {...rest} />);className arrives through ComponentProps<'button'>, so pull it out by name. Left inside ...rest, it would land on the element raw and replace your own classes.
type Props = ComponentProps<'button'> & { variant?: 'primary' | 'destructive' | 'ghost'; size?: 'sm' | 'md' | 'lg';};
const Button = ({ variant = 'primary', size = 'md', className, ...rest}: Props) => ( <button className={cn(buttonClasses({ variant, size }), className)} {...rest} />);Compute your component’s classes first, then pass the caller’s className last so it wins on conflicts. It’s the same cn() className-last rule, applied to the new props.
type Props = ComponentProps<'button'> & { variant?: 'primary' | 'destructive' | 'ghost'; size?: 'sm' | 'md' | 'lg';};
const Button = ({ variant = 'primary', size = 'md', className, ...rest}: Props) => ( <button className={cn(buttonClasses({ variant, size }), className)} {...rest} />);...rest is everything you didn’t destructure, like disabled, type, onClick, and aria-label, spread onto the native element. The caller’s attributes reach the real button with zero per-attribute typing.
(buttonClasses stands in for the function that turns variant and size into a class string, which cva becomes in a later lesson. Just reference it.)
That ...rest spread is what makes the component a thin, faithful wrapper. Anything the consumer needs from the native button, they get, without you anticipating it.
ComponentProps vs the older forms
Section titled “ComponentProps vs the older forms”You’ll meet two other ways to type native button props in code written before 2025, or in older shadcn components. Recognize them, and know why ComponentProps is the default.
type Props = ComponentProps<'button'> & { variant?: 'primary' | 'destructive' | 'ghost';};Use this. One alias, no element type to name, and it pulls everything the JSX <button> accepts: attributes, handlers, and ref.
type Props = ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'destructive' | 'ghost';};The older per-element form. It works, but it’s more verbose and names the element twice, in the type and in the generic. No reason to prefer it.
type Props = HTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'destructive' | 'ghost';};A subtle bug. HTMLAttributes carries the attributes every element has, but not button-specific ones like type or disabled. Reach for it expecting button props and those props simply aren’t in the type, with no error to point you at the gap.
Use ComponentProps<'button'> for the wrapped-element props, every time. The other two are there for you to recognize, and the last one to avoid.
Next, a <Button> that hand-types only what it consumes, and a failing test that passes native attributes straight through:
This Button hand-types only what it consumes, so the disabled, type, and className the caller passes never reach the real <button>. Retype ButtonProps with ComponentProps<'button'> so it inherits every native attribute, then destructure { variant, className, ...rest } and spread ...rest onto the element — merging the caller's className last so your own classes survive.
Reveal the forwarding Button
import type { ComponentProps } from 'react';
type ButtonProps = ComponentProps<'button'> & { variant?: 'primary' | 'destructive';};
function Button({ variant = 'primary', className, ...rest }: ButtonProps) { return <button className={cn('rounded-md px-3 py-1.5', className)} {...rest} />;}ComponentProps<'button'> pulls in disabled, type, onClick, aria-label, and every other native button prop, so variant is the only thing you declare by hand. The destructure names the two props the component consumes, variant and className, and gathers the rest into ...rest. Spreading {...rest} carries disabled and type="submit" through to the real element. Because className arrives inside ComponentProps<'button'>, you pull it out and merge it with cn('rounded-md px-3 py-1.5', className), your classes first and the caller’s last so it wins on conflicts. Leave it inside ...rest instead and the spread sets it raw, dropping rounded-md.
(The exercise runtime has no cn in scope, so concatenate there: className={`rounded-md px-3 py-1.5 ${className ?? ''}`}. In the real project you reach for cn from @/lib/utils, which also de-duplicates conflicting Tailwind classes; the shape is otherwise identical.)
Typing wrappers and component references
Section titled “Typing wrappers and component references”These two helpers are a pair: one goes from a component to its props, the other from props to a component. Seeing them together keeps them straight.
The first types a wrapper. Say you build a <ConfirmButton> that’s just your <Button> pre-set to the destructive variant, plus a confirmLabel. Rather than restate every variant and native attribute the inner Button exposes, you inherit them: ComponentProps<typeof Button> extracts the full props type of an existing component.
The second types a component reference, the component itself rather than its props. Where ComponentProps<typeof X> pulls the props out of a component, ComponentType<P> is the type of a component that accepts props P. Reach for it when a component is a value you store or pass: a registry mapping names to components, or a prop that is a component, like a Lucide icon to render inside a button.
type Props = ComponentProps<typeof Button> & { confirmLabel: string;};
const ConfirmButton = ({ confirmLabel, ...rest }: Props) => ( <Button variant="destructive" {...rest}> {confirmLabel} </Button>);ComponentProps<typeof Button> inherits the inner component’s whole surface. Every variant, size, and native attribute Button exposes flows through, so you only declare what ConfirmButton adds.
type Props = { icon: ComponentType<{ className?: string }>; label: string;};
const IconButton = ({ icon: Icon, label }: Props) => ( <button> <Icon className="size-4" /> {label} </button>);ComponentType<P> is the type of a component itself. You pass the icon component in (not an element), rename it to a PascalCase local, and render it where you choose.
ComponentType<P> returns much later, where components get passed around as data. Recognizing it now is enough.
Every component accepts a className
Section titled “Every component accepts a className”This is a discipline rather than a mechanic, so it stands on its own. The rule:
Every component the project ships accepts an optional
classNameand merges itcn(..., className)at its outermost element.
Your <Button> already does this; here it’s named so you apply it everywhere.
<button className={cn(buttonClasses({ variant, size }), className)} {...rest} />A component that refuses className looks fine until the first consumer needs one different margin, and then they’re stuck forking it or wrapping it in an extra <div>. Accepting and merging it last costs one line and keeps the component usable in every layout. Don’t lock styling down; the escape hatch is the feature.
No new exercise is needed: the test in the previous exercise already checks that a caller’s className survives onto the element.
Discriminated unions for mutually-exclusive props
Section titled “Discriminated unions for mutually-exclusive props”The variant union picked one of N appearances. A different shape needs a sharper tool: two props where exactly one must be set and never both.
The canonical case is something that’s either a link or a button. A link needs an href; a button needs an onClick. Setting both is a bug, and so is setting neither. Two optionals plus a runtime guard describe the rule but enforce nothing, since a caller can still pass both. A discriminated union makes the illegal combination a compile error:
type Props = { href?: string; onClick?: () => void;};Describes the rule, enforces nothing. Both props optional means a caller can pass both or neither, and the body has to guard at runtime against shapes the type let through.
type Props = | { href: string; onClick?: never } | { onClick: () => void; href?: never };Makes the illegal combo a compile error. Each branch requires one prop and forbids the other with ?: never. Passing both matches neither branch; passing neither fails the same way.
This has a cost. Discriminated-union props need a discriminant or careful narrowing in the body to tell the branches apart, friction every time you read the component. So reach for it only when the mutual exclusion is real: the link-or-button case earns it, a typical button’s props don’t.
This is the typed answer to the button-versus-link question. The next lesson on polymorphic components solves it by composition instead, letting one component become a link or a button, and you’ll weigh the two; for now, reach for the discriminated union when the exclusion lives in the types.
Now make the type do that work. The two-optionals Props below lets every call site through, including the two that should be bugs. Each bad call carries an @ts-expect-error directive, but the flat shape permits both lines, so those directives sit unused, the red in the diagnostics panel. Rewrite Props as a discriminated union so the illegal calls finally error and satisfy the directives, while the two legitimate shapes still type-check.
Rewrite Props so that passing both href and onClick — or neither — is a type error, while a lone href or a lone onClick still type-checks. Give each branch one required prop and forbid the other with ?: never. When the union is right, the two @ts-expect-error directives stop being unused and the diagnostics clear.
- Fix all errors
Reveal the discriminated union
type Props = | { href: string; onClick?: never } | { onClick: () => void; href?: never };Each branch makes one prop required and forbids the other with ?: never. asLink matches the first branch and asButton the second. both matches neither: its onClick violates the first branch’s onClick?: never and its href violates the second’s href?: never. neither ({}) also matches no branch, since each branch has a required prop an empty object can’t supply. Both @ts-expect-error directives are now satisfied and the diagnostics panel clears.
Generic components, lightly
Section titled “Generic components, lightly”Sometimes a component must work for any item type, like a <List> that doesn’t care whether it renders invoices, users, or tags. That’s a generic component, and it’s one you’ll write rarely but recognize often. It takes the data and a function that renders one item:
type ListProps<Item> = { items: Item[]; render: (item: Item) => ReactNode;};
const List = <Item extends { id: string }>({ items, render }: ListProps<Item>) => ( <ul> {items.map((item) => ( <li key={item.id}>{render(item)}</li> ))} </ul>);
const InvoiceList = ({ invoices }: { invoices: Invoice[] }) => ( <List items={invoices} render={(invoice) => <span>{invoice.number}</span>} />);Two things in that signature are worth a beat. The constraint <Item extends { id: string }> says the list works for any type with an id, which is what lets you write key={item.id}. It’s the same <T extends ...> instinct as any constrained generic, with one twist: in a .tsx file a bare <Item> reads as an opening JSX tag, so the generic and JSX syntaxes collide. The extends clause resolves it; without a constraint, a trailing comma (<Item,>) does the same job. This is what catches people who know TypeScript generics cold but have never written one inside .tsx.
The payoff is at the call site. InvoiceList passes invoices, the component infers Item is Invoice from the array, and inside render the invoice parameter is fully typed with no annotation. Full type safety, nothing to declare. Hold onto this one for recognition rather than daily use.
One component per file (and the one exception)
Section titled “One component per file (and the one exception)”The conventions for where components live on disk are short:
- One component per file, by default. The filename is kebab-cased and matches the export:
button.tsxexportsButton,confirm-button.tsxexportsConfirmButton. - Internal sub-components used only by that file stay in that file. Don’t split out a helper nobody else imports.
- The exception: a tightly-coupled compound set ships as one file that exports the whole surface.
<Card>,<CardHeader>,<CardContent>, and<CardFooter>all come fromcard.tsx, because you never use one without the others.
Directorycomponents/
Directoryui/
- button.tsx exports
Button(the default: one component per file) - input.tsx exports
Input - card.tsx exports
Card,CardHeader,CardContent,CardFooter(the exception: a compound set in one file)
- button.tsx exports
That compound set is exactly what the next lesson teaches: how a set of components composes into one.
Legacy patterns to recognize, not write
Section titled “Legacy patterns to recognize, not write”A few patterns surround typed components in older codebases. Know them on sight, but don’t reach for them.
The next lessons extend this <Button>: composition and children, then Slot and cva for polymorphic variants.
External resources
Section titled “External resources”The official React guide to typing props, events, and hooks, the canonical reference for this lesson.
React docs on the prop boundary itself: passing, destructuring, defaults, and the spread-forward pattern.
Matt Pocock on the lesson's centerpiece: extracting props from elements and components with one alias.
The community reference for typing React with TypeScript: props patterns, events, and gotchas in one place.