Skip to content
Chapter 22Lesson 1

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.

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

1 / 1

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.

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.

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.

Three booleans → 8 states
isPrimaryisDestructiveisGhost
FFF no appearance
FFT ghost
FTF destructive
FTT two appearances at once
TFF primary
TFT two appearances at once
TTF two appearances at once
TTT two appearances at once
collapses to
One union → 3 states
primary
destructive
ghost
Three booleans can express eight states; the variant union expresses exactly the three that are real.

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.

Variant union One axis, mutually-exclusive options
Stays a boolean Independent on/off flag
variant
size
tone
align (start | center | end)
disabled
loading
fullWidth

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

1 / 1

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

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.

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.

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

    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.

    Button a component
    ButtonProps a props type
    WidgetProps a props type
    Widget a component
    One helper goes component → props; its dual goes props → component.

    ComponentType<P> returns much later, where components get passed around as data. Recognizing it now is enough.

    This is a discipline rather than a mechanic, so it stands on its own. The rule:

    Every component the project ships accepts an optional className and merges it cn(..., 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.

    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
    Booting type-checker…
    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.

    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.tsx exports Button, confirm-button.tsx exports ConfirmButton.
    • 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 from card.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)

    That compound set is exactly what the next lesson teaches: how a set of components composes into one.

    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.