C.W.K.
Stream
Lesson 02 of 07 · published

Typed Props — Contracts the Compiler Enforces

~16 min · typescript, props, generics

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
A typed prop is a small treaty between the component author and every caller. The compiler is the only neutral party. Honor the treaty and your IDE becomes a second pair of eyes.

The four ways to type props

  1. Inline object type — fastest, most local. Good for small components.
  2. Named type alias — exported, reusable, composable with intersection (&) and union (|).
  3. Named interface — like type, but extensible via declaration merging. Use for prop shapes you expect third parties to extend.
  4. Inferred from a function signature — for HOC-style helpers that wrap a component.

In practice, named type aliases win. They're flexible (unions, intersections, mapped types all work), and they don't accidentally collide via declaration merging.

The children prop

Almost every layout component takes children. The canonical type is React.ReactNode — covers strings, numbers, JSX, fragments, arrays, null, undefined, and booleans. Don't try to narrow it unless you have a real reason (e.g. 'only accept a single string'); you'll just fight legitimate uses.

The native-element extension pattern

A common shape: your component renders a <button> with extra behavior, and you want callers to be able to pass any native button prop (onClick, aria-label, disabled, etc.) without you re-declaring each one. The TS-native way: extend React.ComponentPropsWithoutRef<'button'>.

Generics in components

Generic components are how you build a typed <List items={users} renderItem={(u) => ...} /> where TypeScript infers that u is a User from the type of users. The syntax has a quirk in .tsx files (the type parameter <T> looks like JSX), which you'll see in the code block.

Type the contract, not the implementation. Your props type describes what callers must pass and what they can expect. It is the public face. Internal state, refs, derived values — those don't belong in the props type. Keeping the boundary clean keeps refactors local.

Code

The four patterns side-by-side·tsx
// 1. Inline
function Hello1({ name }: { name: string }) {
  return <p>Hello, {name}</p>;
}

// 2. Named type alias (the workhorse)
type Hello2Props = {
  name: string;
  count?: number; // optional
};
export function Hello2({ name, count = 0 }: Hello2Props) {
  return <p>Hello, {name} (×{count})</p>;
}

// 3. Interface (declaration-merging friendly)
export interface Hello3Props {
  name: string;
}
export function Hello3({ name }: Hello3Props) {
  return <p>Hello, {name}</p>;
}

// 4. Inferred — wrap an existing component
export function withSilly<P>(Inner: React.ComponentType<P>) {
  return function Sillied(props: P) {
    return <Inner {...props} />;
  };
}
Native-element extension — forward unknown props to the underlying button·tsx
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
  variant?: "primary" | "ghost";
};

export function Button({ variant = "primary", className, ...rest }: ButtonProps) {
  const base = "px-4 py-2 rounded-lg font-medium transition-colors";
  const tones = {
    primary: "bg-brand text-bg hover:bg-brand-strong",
    ghost: "bg-transparent text-fg hover:bg-bg-elevated",
  };
  return (
    <button
      className={`${base} ${tones[variant]} ${className ?? ""}`}
      {...rest}
    />
  );
}

// Caller can pass any native button prop without you re-declaring them:
// <Button onClick={...} aria-label="Save" disabled type="submit" />
Generic component — inferred item type·tsx
// In .tsx the <T,> trailing comma disambiguates from JSX.
type ListProps<T> = {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
  empty?: React.ReactNode;
};

export function List<T>({ items, renderItem, empty }: ListProps<T>) {
  if (items.length === 0) return <>{empty}</>;
  return (
    <ul>
      {items.map((item, i) => (
        <li key={i}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

// Usage — T is inferred as { id: string; name: string }
// <List items={users} renderItem={(u) => u.name} />

External links

Exercise

Build a typed Input component that extends React.ComponentPropsWithoutRef<'input'> and adds two props: label: string and error?: string. Render a <label> wrapping the input with the error (when present) shown in red below. Use the Input component three ways: (1) plain text input, (2) with onChange + value, (3) with type='email' + aria-label. Confirm TypeScript accepts all native input props without you listing them.
Hint
Spread the rest props onto the inner <input>. Use ...rest after destructuring out the props you handle yourself (label, error, possibly className).

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.