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.
Table of Contents
- 1. Why bother typing React props at all
- 2. The base interface: required fields and structure
- 3. Typing children correctly: ReactNode vs. ReactElement
- 4. Optional props and sensible default values
- 5. Variants with union types and discriminated props
- 6. Typing render props and function-as-children
- 7. Extending native HTML attributes with ComponentPropsWithoutRef
- 8. Compile-time types vs. runtime validation
- 9. Props typing compared: approaches and their limits
- 10. Summary
- 11. FAQ
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.