Typing React Props and Children in TypeScript
AI generated
<T>
type
TypeScript · React · Props · Children
Typing React Props and Children in TypeScript
From the first interface to extended HTML components

Anyone who writes React props without clear types relies on convention instead of the compiler. TypeScript turns silent assumptions about props into explicit contracts: which values are required, which are optional, what shape children may take, and which extra HTML attributes a component should pass through. This article shows how to type React props from the first interface definition all the way to extended, reusable components.

13 min read Props Interface · ReactNode · ComponentPropsWithoutRef TypeScript 5.x · React 18/19

1. Why bother typing React props at all

A React component without typed props works in the editor like a black box: you see the function name, but not which values are expected, which of them are required, and what shape the result takes. In a plain JavaScript component, this information lives at best in a comment that goes stale on the next refactor, with nothing pointing that out to the developer. TypeScript makes these contracts explicit by declaring React props as a named interface that the compiler checks at every single call site.

The practical benefit shows up most clearly during refactoring. If a required prop is removed or renamed, the compiler immediately flags every call site that still uses the old name as an error. Without this safety net, the mistake stays hidden until runtime, often only becoming visible when a user reports a blank page or a crash. Especially in teams where multiple developers use the same components from different modules, this feedback during compilation is the decisive difference between a safe refactor and a fragile one.

A second benefit concerns the editor experience itself: once React props are typed, autocomplete shows exactly the available names, including tooltips with comments taken straight from the interface. New team members do not need to open the component to understand which props exist, they see it directly at the call site. This self-documentation is one of the underrated effects of consistent typing in React projects.

2. The base interface: required fields and structure

The most common way to type React props is a dedicated interface per component, named following the pattern ComponentNameProps. Every field in the interface corresponds to exactly one expected prop, and the type describes the allowed values. Required fields are declared without a question mark, so the compiler throws an error the moment a call site forgets one. This structure is deliberately simple because it is sufficient for most components and does not require advanced type constructs.

It matters to export the interface right next to the component instead of only using it locally. Other modules that use the same component inside wrapper components or tests can then import the props interface directly instead of redefining it. That prevents two slightly different descriptions of the same props from existing in the project and drifting apart over time.


// Basic Props interface with required and typed fields
export interface ProductCardProps {
  id: number;
  name: string;
  price: number;
  currency: "EUR" | "USD" | "CHF";
  onAddToCart: (id: number) => void;
}

export function ProductCard(props: ProductCardProps) {
  const { id, name, price, currency, onAddToCart } = props;

  return (
    <div className="rounded-lg border p-4">
      <h3 className="font-semibold">{name}</h3>
      <p>{price.toFixed(2)} {currency}</p>
      <button onClick={() => onAddToCart(id)}>Add to cart</button>
    </div>
  );
}

// Compile error: onAddToCart is missing
// <ProductCard id={1} name="Keyboard" price={79} currency="EUR" />

3. Typing children correctly: ReactNode vs. ReactElement

One of the most common questions when typing React props concerns the children prop. React itself allows a surprisingly wide range of values for children: strings, numbers, single elements, arrays of elements, fragments, even null and undefined. The ReactNode type from the react package captures exactly this range and is therefore the right choice for almost any component that renders arbitrary content, such as a card or layout wrapper.

The picture changes when a component can only handle exactly one React element as its child, for instance because it forwards a ref or extra props onto that single child via cloneElement. In this case ReactElement is the more precise choice, because it explicitly excludes strings, numbers, and arrays, and the compiler rejects incorrect usage already at compile time. Anyone who uses ReactNode everywhere gives up exactly this precision and pushes potential bugs into runtime.


import type { ReactNode, ReactElement } from "react";

// Accepts any renderable content: text, elements, fragments, arrays
interface CardProps {
  title: string;
  children: ReactNode;
}

function Card({ title, children }: CardProps) {
  return (
    <section className="rounded-lg border p-4">
      <strong>{title}</strong>
      {children}
    </section>
  );
}

// Requires exactly one React element as child (e.g. for cloneElement)
interface TooltipTriggerProps {
  children: ReactElement;
  label: string;
}

function TooltipTrigger({ children, label }: TooltipTriggerProps) {
  return (
    <span title={label}>
      {children}
    </span>
  );
}

// Compile error: a string is not a ReactElement
// <TooltipTrigger label="Info">Just text</TooltipTrigger>

4. Optional props and sensible default values

Not every prop is strictly required on every call. Adding a question mark in the interface, for example size?: "sm" | "md" | "lg", marks React props as optional so call sites may omit them. The compiler then allows both a call without this prop and a call with one of the specified values, while consistently rejecting any other string. This combination of optionality and a restricted set of values is far more precise than a plain size?: string, which would allow any arbitrary text.

For the actual default value, the cleanest approach is a default parameter in the destructuring, not a separate defaultProps assignment, which is considered deprecated for function components in modern React versions. The advantage of default parameters: the type of the destructured variable stays non-optional inside the component, because TypeScript knows the default value is guaranteed to apply whenever the prop is missing. That removes unnecessary null checks throughout the rest of the component code.


interface ButtonProps {
  label: string;
  size?: "sm" | "md" | "lg";
  disabled?: boolean;
  onClick?: () => void;
}

// Default parameters keep `size` non-optional inside the component body
function Button({ label, size = "md", disabled = false, onClick }: ButtonProps) {
  const paddingClass = size === "sm" ? "px-2 py-1" : size === "lg" ? "px-6 py-3" : "px-4 py-2";

  return (
    <button className={paddingClass} disabled={disabled} onClick={onClick}>
      {label}
    </button>
  );
}

// Both calls are valid, size/disabled fall back to their defaults
// <Button label="Save" />
// <Button label="Delete" size="sm" disabled />

5. Variants with union types and discriminated props

Some components have several variants whose props mutually exclude each other. An alert might either show a plain message or additionally offer a retry action, but never sensibly combine both forms at once. A single flat interface with only optional fields allows invalid combinations in this case, for example an onRetry function without the matching variant type. React props can instead be modeled as a discriminated union, where a shared field, usually variant, determines which of the remaining fields are valid.

The compiler uses this shared field as a discriminant: as soon as the code checks props.variant, TypeScript automatically narrows the type inside that branch to the matching variant and makes the corresponding extra fields accessible without further checks. This modeling structurally prevents an invalid combination of props from ever being written, instead of only catching it at runtime with an if check.


// Discriminated union: "variant" determines which extra props are valid
type AlertProps =
  | { variant: "info"; message: string }
  | { variant: "error"; message: string; onRetry: () => void };

function Alert(props: AlertProps) {
  if (props.variant === "error") {
    // TypeScript narrows props to the "error" branch here
    return (
      <div role="alert">
        <p>{props.message}</p>
        <button onClick={props.onRetry}>Retry</button>
      </div>
    );
  }

  return <div role="status">{props.message}</div>;
}

// Compile error: onRetry is not allowed on the "info" variant
// <Alert variant="info" message="Saved" onRetry={() => {}} />

6. Typing render props and function-as-children

Some components do not impose a fixed structure on their children, instead passing data to a function that the calling code defines itself, for example a list that lets each item render arbitrary markup. For this pattern, children is typed not as ReactNode, but as a function with an exact parameter and return type, for example children: (item: T) => ReactNode. This signature makes visible which data the component passes to the render function and what result is expected.

Combined with a generic type parameter, this pattern can be reused for arbitrary data types without writing a separate component for every list. The advantage over a loose any function: inside the render function, the compiler knows the exact shape of item, including autocomplete for its fields, and rejects access to non-existent properties already at compile time.


interface ListProps<T> {
  items: T[];
  children: (item: T, index: number) => ReactNode;
}

// Generic component: T is inferred from the "items" prop
function List<T>({ items, children }: ListProps<T>) {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={index}>{children(item, index)}</li>
      ))}
    </ul>
  );
}

interface Order { id: number; total: number; }

const orders: Order[] = [{ id: 1, total: 49.9 }, { id: 2, total: 129 }];

// Usage: T is inferred as Order, item.total autocompletes
// <List items={orders}>{(order) => <span>{order.total} EUR</span>}</List>

7. Extending native HTML attributes with ComponentPropsWithoutRef

Many custom components are thin wrappers around a native HTML element, for example a custom button that, besides its own click handler, should also support disabled, type, aria-label and every other usual button attribute. Recreating this full list by hand as a custom interface would be error prone and would go stale with every new HTML standard. React provides the helper types ComponentPropsWithoutRef<"button"> and ComponentProps<"button"> for this, which capture exactly the props of the corresponding native element, including every standard and ARIA attribute.

The custom props type extends this base type via intersection and only adds the extra, custom fields, for example isLoading. That way the custom component works at the call site exactly like a native <button>, accepting onFocus, className, or form as well, without those attributes needing to be manually listed in the custom interface. This technique is the standard way to keep React props for wrapper components robust against new HTML attributes.


import type { ComponentPropsWithoutRef } from "react";

// Extends every native button attribute, adds one custom field
type IconButtonProps = ComponentPropsWithoutRef<"button"> & {
  isLoading?: boolean;
};

function IconButton({ isLoading = false, children, disabled, ...rest }: IconButtonProps) {
  return (
    <button disabled={disabled || isLoading} {...rest}>
      {isLoading ? "..." : children}
    </button>
  );
}

// All native button attributes are available, plus isLoading
// <IconButton type="submit" aria-label="Save" isLoading>Save</IconButton>

8. Compile-time types vs. runtime validation

An important point that is often overlooked when typing React props: TypeScript types exist exclusively at compile time and disappear entirely once the code is compiled to JavaScript. When props come from an external source that is not checked by TypeScript, for example a JSON response from an API endpoint or dynamically loaded CMS data, the interface alone does not guarantee that the actual values match at runtime. The compiler trusts the declared type, even if the real data source delivers something different.

For these edge cases, plain typing is complemented with runtime validation, for example using a library like Zod, which derives both the TypeScript type and an actual runtime check from a single schema. Inside the React application itself, where props are passed directly from a parent component, pure compile-time checking is entirely sufficient, because both sides are subject to the same type system. The rule of thumb: runtime validation at the boundaries of the application, plain TypeScript interfaces for everything that flows within the React tree.

9. Props typing compared: approaches and their limits

The following overview summarizes which approach to typing React props fits which scenario and where its respective limits lie.

Scenario Unsuitable approach Recommended approach Benefit
Arbitrary content as a child children: ReactElement children: ReactNode Correctly allows text, arrays and fragments
Mutually exclusive variants flat interface, every field optional Discriminated union over variant Invalid combinations are impossible
Wrapper around a native element list attributes manually in the interface ComponentPropsWithoutRef<"button"> Stays current with new HTML attributes
Props from an external API Trust only the TypeScript interface Zod schema at the API boundary Checks actual values at runtime
Custom rendering per item children: ReactNode with an any cast children: (item: T) => ReactNode Full type checking inside the render function

No single approach covers every scenario equally well. Anyone who uses ReactNode for every situation loses the precision that a discriminated union or a specific function signature would offer. Anyone who instead gives every component its own fully written out attribute list causes unnecessary maintenance work that ComponentPropsWithoutRef already handles automatically. The right choice depends on the concrete use case of the given component, not on a blanket rule.

Mironsoft

TypeScript tooling, type-safe React components and Magento/Hyvä integrations

React components with clean, type-safe props?

We analyze existing React components, replace loose any props with clear interfaces, discriminated unions and extended HTML types, and establish consistent props contracts across your entire frontend.

Props audit

Reviewing existing components for loose or missing props types

Component refactoring

Introducing interfaces, discriminated unions and ComponentPropsWithoutRef

Training

Team workshop on props patterns and type-safe React components

10. Summary

Typing React props starts with a simple, exported interface for required fields and is extended by question marks for optional values, default parameters for sensible defaults, and union types for restricted values. Children can be precisely described with ReactNode for arbitrary content or ReactElement for exactly one child, while discriminated unions prevent mutually exclusive prop combinations already at compile time. For wrappers around native HTML elements, ComponentPropsWithoutRef automatically supplies every relevant attribute without manual upkeep for every new HTML standard.

What matters is the boundary between compile time and runtime: TypeScript types disappear entirely once the code is compiled, so every prop coming from an external, not type-checked source additionally needs real runtime validation. Inside the React tree itself, where the parent component and the child component are subject to the same type system, plain interface typing is sufficient. Anyone who applies these principles consistently ends up with React components whose props contracts enforce themselves during refactoring instead of only surfacing as runtime errors.

Typing React Props and Children - The Essentials at a Glance

Base interface

One exported interface per component, required fields without a question mark, precise restricted values instead of plain string.

Children

ReactNode for arbitrary content, ReactElement for exactly one child, function signatures for render props.

Variants & native elements

Discriminated unions for mutually exclusive props, ComponentPropsWithoutRef for wrappers around HTML elements.

Runtime boundary

TypeScript checks only at compile time, external data sources need Zod or comparable runtime validation on top.

11. FAQ: Typing React Props and Children

1How do I type props in a React component?
With an exported interface per component naming every expected field. The compiler then checks every call site against this interface.
2ReactNode or ReactElement for children?
ReactNode for arbitrary content including text and arrays. ReactElement only when exactly one concrete element is expected, for example for cloneElement.
3Optional prop with a default value?
Question mark in the interface plus a default parameter during destructuring. Inside the component, the value then stays typed as non-optional.
4What is a discriminated union for props?
A union type with a shared field like variant that determines which extra fields are valid. Prevents invalid prop combinations already at compile time.
5Extending a custom component with button attributes?
Combine ComponentPropsWithoutRef<'button'> via intersection. Automatically covers every standard and ARIA attribute.
6ComponentProps or ComponentPropsWithoutRef?
ComponentPropsWithoutRef excludes ref and is the common choice for wrappers that forward ref themselves via forwardRef.
7Is an interface enough for API props?
No, TypeScript types disappear at runtime. Add runtime validation like Zod at the API boundary for external data sources.
8How do I type render props?
With a function signature like children: (item: T) => ReactNode instead of plain ReactNode, combined with a generic type parameter.
9Why not just use any for props?
any switches off every check. Typos and missing required fields then only surface at runtime instead of already at compile time.
10Should the props interface be exported?
Generally yes, so wrapper components and tests import the same interface instead of defining a diverging copy.