The typed props contract
How an experienced React developer writes a component's props as a typed contract, the foundation the rest of this chapter on components and composition builds on.
Here are two ways to write the same Save button. Read both, and notice which one you’d rather inherit a year from now.
<Button isPrimary isLarge withIcon iconName="save" iconPosition="left" loading={false}> Save</Button>Every prop here is independent, and nothing constrains anything. There are six props, and every one is a boolean or a stringly-typed flag. Nothing stops you from setting isPrimary and isSecondary both to true, or pairing iconName="save" with withIcon={false}. The list only grows, because every design tweak adds another boolean to remember.
<Button variant="primary" size="lg"> Save</Button>Two named axes, each with a fixed set of mutually-exclusive options. The appearance lives on variant, the scale lives on size, and primary and secondary can’t both be set because they’re values of the same prop. There’s nothing to misconfigure, and the editor autocompletes the allowed values.
The problem with the first version isn’t any single prop. It’s the shape. Every prop is an independent flag, the flags don’t constrain each other, and the only way to learn what’s legal is to read the source. The second version is a contract: a small named surface that says exactly what it accepts and rejects everything else before the code even runs.
Up to now you’ve styled elements: a <button>, a <div>, a <section> that you owned at the call site and dressed with Tailwind. In this lesson you stop styling tags and start writing the thing other code calls. A React component is a function that takes props and returns JSX, and its props are a contract, a promise about what it accepts. This lesson covers the form an experienced developer reaches for when writing that contract in 2026, 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. You can write JSX, type a union and a discriminated union, and merge class strings with cn(). This lesson is where those three meet on a function boundary. 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”Underneath every refinement, 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 right 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>;That works, but you won’t usually inline the type. Once a component has more than one prop, pull the shape out 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>);A component is an arrow function bound to const, named in PascalCase. That casing is not a style choice, it changes behavior: write badge in lowercase and JSX treats <badge> as a literal DOM tag, dropping every prop you pass it without an error. 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 directly in the parameter and annotate that parameter 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>);The convention is type Props for component props, never interface. This is the same call you met when you first weighed type against interface: type by default, interface only for declaration merging, which props never do. Notice too that Badge has no return type. Inference already knows it returns a JSX.Element, so annotating it only adds noise.
Two details are worth restating. First, the PascalCase rule is not a style preference, it changes behavior. A lowercase component name makes React render a literal DOM element of that name and discard your props, with no error to warn you. Name components Badge, SubmitButton, PriceTag, never badge.
Second, you don’t annotate the return type. The body returns JSX, so TypeScript infers the JSX.Element for you, and writing : JSX.Element after the parameter list just repeats what the compiler already knows. Annotate the inputs and let the output infer.
Typing props: strings, unions, and function signatures
Section titled “Typing props: strings, unions, and function signatures”The Props type is where the contract gets written, so it’s worth knowing the vocabulary it’s built from. You already know TypeScript, so this is application rather than new instruction. Four shapes cover almost every prop you’ll ever type.
type Props = { title: string; count: number; variant: 'primary' | 'secondary' | 'destructive'; icon?: string; onClick: (event: MouseEvent<HTMLButtonElement>) => void;};Three of those are familiar on sight: a string, a number, and an optional prop marked with ?. The fourth carries the one genuinely new piece.
The union of string literals, variant: 'primary' | 'secondary' | 'destructive', is the workhorse of component props, and it’s the shape the next section builds on in full. It says this prop is exactly one of these three strings, so the editor autocompletes them and rejects anything else. Any time a prop has a small, fixed set of legal values, this is the type you reach for.
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. The new detail is MouseEvent: it’s React’s synthetic event type, imported from react, not the MouseEvent that lives on the DOM global. The generic argument matters, because MouseEvent<HTMLButtonElement> types event.currentTarget as a button element, so reading event.currentTarget.disabled type-checks. Name the wrong element there and that property access stops type-checking.
One habit to carry onto this surface: never type a prop any. It’s the same instinct you already have, to 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.
Defaults belong at the destructure
Section titled “Defaults belong at the destructure”There’s a single idea in this section: default values go in the parameter destructure, right next to the prop they apply to.
const Button = ({ variant = 'primary', size = 'md' }: Props) => { // ...};Two things matter here. First, defaultProps, the old object you’d hang on a component to supply defaults, is gone for function components, because React 19 removed it. You won’t see it in new code, and defaulting at the destructure is the only form now.
Second, this beats a falsy check in the body. You might be tempted to write const v = variant || 'primary' instead, but that’s the || trap on a new surface. || fires on any falsy value, so a legitimate falsy prop gets overridden by accident, and the default ends up scattered away from the signature where you can’t see it. The destructure default fires only on undefined. Pass nothing, or pass 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, and why a union prop never has a falsy member to trip over.
From boolean piles to a variant union
Section titled “From boolean piles to a variant union”Now apply all of that to a pile of appearance flags. 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. One axis, one prop. The variants are mutually exclusive by construction, since you cannot set two at once, and there’s nothing illegal left to represent.
It’s worth walking the reasoning, because this is a judgment call you’ll make over and over.
Mutually exclusive by construction. Three booleans give you 2³ = 8 combinations, and five of them are meaningless: anything with two flags on, plus the all-off state. The union gives you exactly three. 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 the new case. Three independent booleans give you no such safety net: nothing connects them, so there’s nothing for the compiler to check against.
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 rather than spread across booleans.
One connection is worth naming here. These unions are the source of the variant table that class-variance-authority reads later in this chapter: the variant and size props you’re defining now become the keys cva maps to class strings. You’re building the input that tool will consume, so keep the shape clean.
Once this clicks, there’s an easy overcorrection: turning everything into a union. The rule isn’t always collapse. 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. Those are independent switches, and forcing them into a union would be 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”This section is the centerpiece of the lesson. Your <Button> wraps a native <button>, and a native <button> accepts a lot: disabled, type, aria-label, onClick, onFocus, form, name, every standard attribute and every event handler. If the consumer can’t pass those through your component to the real element underneath, your <Button> is a downgrade from the raw tag. Hand-typing each one in Props is both endless and always one attribute behind.
The solution is one type and one spread, and they’re really a single idea.
First, reach for a React utility type called ComponentProps . It pulls in everything the JSX <button> accepts, and you intersect it with the props your component adds on top:
type Props = ComponentProps<'button'> & { variant?: 'primary' | 'destructive' | 'ghost'; size?: 'sm' | 'md' | 'lg';};ComponentProps<'button'> is the type of every attribute, every event handler, and, in React 19, the ref too, all under one alias. You never list them: the alias is the list, and it stays correct as the platform evolves.
Then, in the body, destructure the props your component actually 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. The component now 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 comes in through ComponentProps<'button'>, so you must pull it out by name. Leave it inside ...rest and you can’t merge it deliberately: 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 as the last argument so it wins on conflicts. This is the same cn() className-last rule you already use, applied on the new props surface.
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 directly onto the native element. The consumer’s native attributes reach the real button with zero per-attribute typing.
(buttonClasses here is a stand-in for the function that turns variant and size into a class string, which is what cva becomes in a later lesson. Don’t build it, just reference it.)
The className-plus-...rest pattern at the heart of that block has one ordering rule you have to get right, and it’s worth understanding rather than memorizing. Because ComponentProps<'button'> includes className, the caller’s className sits inside ...rest unless you pull it out. If you forget to destructure it and just write <button className={cn(buttonClasses(...))} {...rest} />, the spread re-injects the raw caller className, which either overrides your computed classes or sets the attribute twice. The fix is structural: destructure className out of the rest, compute cn(yourClasses, className), and set that one merged value on the element.
That ...rest spread is what makes the component a thin, faithful wrapper instead of a closed one. 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. Learn to recognize them, and know why ComponentProps is the default now.
type Props = ComponentProps<'button'> & { variant?: 'primary' | 'destructive' | 'ghost';};The 2026 default. One alias, no element type to name, and it pulls everything the JSX <button> accepts: attributes, handlers, and ref. This is what you write.
type Props = ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'destructive' | 'ghost';};The older per-element form. It works, but it’s more verbose and you name the element twice, once in the type and once in the generic. There’s no reason to reach for it over ComponentProps.
type Props = HTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'destructive' | 'ghost';};The base attributes only, a subtle bug. HTMLAttributes carries the attributes every element has, but not button-specific ones like type or disabled. Reach for this expecting button props and those props simply won’t be in the type, with no error to point you at the gap.
The takeaway is simple: use ComponentProps<'button'> for the wrapped-element props, every time. The other two are there for you to recognize, and the last one is a mistake to avoid.
The next exercise gives you 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 now the only thing you declare by hand. In the destructure you name the two props the component actually consumes, variant and className, and gather everything else into ...rest. Spreading {...rest} onto the <button> is what carries disabled and type="submit" through to the real element. And because className arrives inside ComponentProps<'button'>, you have to pull it out by name 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 className inside ...rest instead and the spread sets it raw, dropping rounded-md.
(The runtime in the exercise above 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”Here are two type helpers you’ll reach for less often but should recognize now. They’re a pair: one goes from a component to its props, the other from props to a component. They’re easy to mix up, so it helps to see them side by side.
The first types a wrapper. Say you build a <ConfirmButton> that’s just your <Button> pre-set to the destructive variant, plus a confirmLabel. You don’t want to restate every variant and native attribute the inner Button already exposes; you want to inherit them. ComponentProps<typeof Button> does exactly that: it 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. You reach for it when a component is a value you store or pass: a registry that maps names to components, or a prop that is a component, like accepting a Lucide icon to render inside a button without instantiating it at the call site.
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> shows up in earnest much later in the course, where components get passed around as data. Recognizing it now is enough; you’ll work with it properly there.
className is the styling contract, not an option
Section titled “className is the styling contract, not an option”This is short, and it’s a discipline rather than a mechanic, which is why 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 as a rule so you apply it everywhere, not just to buttons.
<button className={cn(buttonClasses({ variant, size }), className)} {...rest} />The reasoning is about reuse. A component that doesn’t accept className looks fine right up until the first consumer needs one different margin, and then they’re stuck forking the component or wrapping it in an extra <div> just to nudge it. Accepting className and merging it last costs one line and keeps the component usable in every layout it lands in. Merging it last means the caller wins on conflicts, exactly as in the cn() rule you already know. Don’t lock styling down; the escape hatch is the feature.
You don’t need a new exercise for this. The test in the previous exercise already checks that a caller’s className survives onto the element, which is this rule enforced in code.
Discriminated unions for mutually-exclusive props
Section titled “Discriminated unions for mutually-exclusive props”The variant union handled picking one of N appearances. There’s a sharper tool for a different shape: when two props must never both be set, and exactly one of them must be.
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 setting neither is a different bug. Two optionals plus a runtime guard describes the rule but doesn’t enforce it, since nothing stops a caller from passing both. A discriminated union makes the illegal combination a compile error:
type Props = { href?: string; onClick?: () => void;};Describes the rule, enforces nothing. Both props being optional means a caller can pass both or pass neither, and the body has to guard at runtime against shapes the type already let through.
type Props = | { href: string; onClick?: never } | { onClick: () => void; href?: never };Makes the illegal combo a compile error. Each branch permits exactly one of the two and forbids the other with ?: never. Passing both href and onClick matches neither branch, so TypeScript rejects the call before it runs. Passing neither fails the same way.
This comes at a cost. Discriminated-union props need a discriminant or careful narrowing in the body to tell the branches apart, and that’s friction every time you read the component. So reach for this only when the mutual exclusion is real. Most props are happily independent and don’t need it: the link-or-button case earns it, a typical button’s props don’t.
The discriminated union is also the typed answer to the button-versus-link question. The next lesson on polymorphic components solves the same problem a different way, by composition, letting one component become a link or a button, and you’ll weigh the two. For now, it’s the tool to reach for when the exclusion lives in the types.
The anti-pattern from the start of the chapter, shipping both onClick and href on one flat button API and sorting it out at runtime, is exactly the shape this fixes. Rather than ship both on a flat API, model the exclusion in the types, or pick one of the two.
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, and since the flat shape permits both lines, those directives are currently unused, which is the red in the diagnostics panel. Rewrite Props as a discriminated union and the illegal calls finally error, satisfying 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 exactly one of the two props required and forbids the other with ?: never. asLink matches the first branch: href is present and onClick is absent, which satisfies onClick?: never. asButton matches the second the same way. both matches neither branch, because its onClick violates the first branch’s onClick?: never and its href violates the second’s href?: never, so TypeScript rejects it and the @ts-expect-error above it is now satisfied. neither ({}) also matches no branch, because each branch has one required prop that an empty object can’t supply. Both directives go quiet, and the diagnostics panel is clear.
Generic components, lightly
Section titled “Generic components, lightly”This is the last technical tool, and one you’ll write rarely but recognize often. Sometimes a component has to work for any item type, like a <List> that doesn’t care whether it’s rendering invoices, users, or tags. That’s a generic component.
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>} />);There are two things in that signature worth a beat. The first is the constraint: <Item extends { id: string }> says the list works for any type that has an id, which is what lets you write key={item.id}, a stable key tied to each row’s identity. It’s the same <T extends ...> instinct you reach for with any constrained generic. The second is what that opening < would mean without the extends clause. 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, and when you don’t need a constraint, a trailing comma (<Item,>) does the same job. This is the detail that trips people who know TypeScript generics cold but have never written one inside .tsx.
The payoff is at the call site. Look at InvoiceList: it passes invoices, and the component infers that Item is Invoice from the array, so inside render the invoice parameter is fully typed as an Invoice with no annotation at the call site. You get full type safety with nothing to declare, which is the whole reason the generic exists.
Hold onto this for recognition. It’s more a confidence builder, proof that the type system can do this, than a daily tool.
One component per file (and the one exception)
Section titled “One component per file (and the one exception)”The last piece is where all of this lives on disk. The conventions 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 one file live in the same 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 pattern is the canonical shadcn shape, and it’s exactly what the next lesson teaches: how a set of components composes into one. Here it’s just the file-layout exception to recognize.
What you’ll see in old code, and won’t write
Section titled “What you’ll see in old code, and won’t write”You now have the full shape: a component is a typed function of props, with a small variant-union API, native attributes inherited through ComponentProps, sensible defaults at the destructure, and a className that stays open. A few patterns surround this in older codebases. Know them on sight, but don’t reach for them.
The <Button> you built is the foundation the next lessons extend: composition and children next, then Slot and cva for polymorphic variants. The mental model carries all the way through. A React component is a typed function from props to JSX, and its props are a contract that is the design. Get that shape right and everything else in this chapter is a refinement of it.
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.