Skip to main content

React Props Interfaces: Define Custom Component Types

Custom prop interfaces let you define reusable, composable type contracts for React components beyond primitive types. By combining interface extension, unions, and readonly modifiers, you build prop types that grow with your application without repetition and enforce consistency across your component library.

Creating Reusable Prop Interfaces

When multiple components share similar props, extract a base interface and extend it. This reduces duplication and keeps your types aligned:

// BaseProps.ts
export interface BaseProps {
id: string;
className?: string;
ariaLabel?: string;
}

// Button.tsx
interface ButtonProps extends BaseProps {
label: string;
variant: 'primary' | 'secondary';
onClick: () => void;
}

const Button: React.FC<ButtonProps> = ({
id,
className,
ariaLabel,
label,
variant,
onClick,
}) => (
<button
id={id}
className={`btn btn-${variant} ${className}`}
aria-label={ariaLabel}
onClick={onClick}
>
{label}
</button>
);

export default Button;

The ButtonProps interface extends BaseProps, automatically inheriting id, className, and ariaLabel. Every button in your app now shares this structure.

Union Types for Restricted Values

Restrict a prop to a specific set of allowed values using union types. This is safer than a string because TypeScript ensures only valid options are passed:

// Card.tsx
interface CardProps {
title: string;
size: 'small' | 'medium' | 'large';
elevation: 1 | 2 | 3 | 4;
layout: 'vertical' | 'horizontal';
}

const Card: React.FC<CardProps> = ({ title, size, elevation, layout }) => {
const sizeClass = {
small: 'w-48',
medium: 'w-72',
large: 'w-96',
}[size];

return (
<div className={`card ${sizeClass} elevation-${elevation}`} data-layout={layout}>
<h2>{title}</h2>
</div>
);
};

// Usage—TypeScript validates
<Card title="Info" size="large" elevation={2} layout="vertical" />

// Error: 'huge' is not a valid size option
<Card title="Info" size="huge" elevation={2} layout="vertical" />

Union types make invalid prop combinations impossible at compile time. Your IDE also shows autocomplete only for valid options.

Nested Interfaces for Complex Data

For components that accept complex objects as props, define interfaces describing the object structure:

// User.tsx
interface Address {
street: string;
city: string;
country: string;
zipCode: string;
}

interface UserProfile {
id: number;
name: string;
email: string;
address: Address;
isVerified: boolean;
}

interface UserCardProps {
user: UserProfile;
onEdit?: (user: UserProfile) => void;
}

const UserCard: React.FC<UserCardProps> = ({ user, onEdit }) => (
<div className="user-card">
<h2>{user.name}</h2>
<p>{user.email}</p>
<p>
{user.address.street}, {user.address.city}, {user.address.country}
</p>
{user.isVerified && <span className="badge">Verified</span>}
{onEdit && (
<button onClick={() => onEdit(user)}>Edit Profile</button>
)}
</div>
);

export default UserCard;

Nested interfaces clarify the expected structure at every level. TypeScript validates that the address has all required fields and prevents missing or misspelled fields.

Readonly Props for Immutability

Mark props as readonly when they should not be mutated inside the component. This documents the contract and prevents accidental modifications:

// FormField.tsx
interface FormFieldProps {
readonly name: string;
readonly label: string;
readonly value: string;
readonly onChange: (value: string) => void;
readonly errors?: readonly string[];
}

const FormField: React.FC<FormFieldProps> = ({
name,
label,
value,
onChange,
errors = [],
}) => {
// TypeScript error: value is readonly
// value = '';

// Correct: call onChange instead of mutating
const handleChange = (newValue: string) => onChange(newValue);

return (
<div className="form-field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
value={value}
onChange={(e) => handleChange(e.target.value)}
/>
{errors.length > 0 && (
<ul className="errors">
{errors.map((err, i) => <li key={i}>{err}</li>)}
</ul>
)}
</div>
);
};

export default FormField;

The readonly keyword tells TypeScript and readers that these props must not be modified. It enforces a unidirectional data flow: props come in, events go out.

Combining Interfaces with Intersection

Intersection types (A & B) combine multiple interfaces, useful when a component accepts props from multiple sources:

// Tooltip.tsx
interface BaseTooltipProps {
content: string;
delay?: number;
}

interface PositionProps {
position: 'top' | 'bottom' | 'left' | 'right';
offset?: number;
}

type TooltipProps = BaseTooltipProps & PositionProps;

const Tooltip: React.FC<TooltipProps> = ({
content,
delay = 0,
position,
offset = 4,
}) => (
<div
className={`tooltip tooltip-${position}`}
style={{ '--tooltip-offset': `${offset}px` } as React.CSSProperties}
data-delay={delay}
>
{content}
</div>
);

export default Tooltip;

The TooltipProps type requires all fields from both BaseTooltipProps and PositionProps. This avoids repeating fields and keeps concerns separate.

Optional Interface Fields and Defaults

Use optional fields (?) for props that are not always required, and provide defaults in the function body:

// Alert.tsx
interface AlertProps {
message: string;
type?: 'success' | 'error' | 'warning' | 'info';
dismissible?: boolean;
autoClose?: number;
}

const Alert: React.FC<AlertProps> = ({
message,
type = 'info',
dismissible = true,
autoClose,
}) => {
const [open, setOpen] = React.useState(true);

React.useEffect(() => {
if (autoClose) {
const timer = setTimeout(() => setOpen(false), autoClose);
return () => clearTimeout(timer);
}
}, [autoClose]);

if (!open) return null;

return (
<div className={`alert alert-${type}`}>
<p>{message}</p>
{dismissible && (
<button onClick={() => setOpen(false)}>Close</button>
)}
</div>
);
};

export default Alert;

Optional fields keep the component flexible while defaults ensure reasonable behavior when props are omitted.

Typing Event Handlers in Props

React event handlers are functions that receive an event object. Typing them correctly ensures safe event handling:

// SearchInput.tsx
interface SearchInputProps {
placeholder: string;
onSearch: (query: string) => void;
onClear?: () => void;
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
}

const SearchInput: React.FC<SearchInputProps> = ({
placeholder,
onSearch,
onClear,
onFocus,
}) => {
const [query, setQuery] = React.useState('');

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
};

return (
<div className="search-input">
<input
type="text"
placeholder={placeholder}
value={query}
onChange={handleChange}
onFocus={onFocus}
/>
<button onClick={() => onSearch(query)}>Search</button>
{query && onClear && (
<button onClick={() => { setQuery(''); onClear(); }}>Clear</button>
)}
</div>
);
};

export default SearchInput;

Handler props specify the event type, so callers know what event properties are available (e.g., e.target.value on an input change event).

Key Takeaways

  • Extend base interfaces to share common props across related components without repeating field definitions.
  • Union types restrict prop values to a set of allowed options, preventing invalid combinations.
  • Nested interfaces clearly describe the structure of complex data props.
  • Mark props as readonly to enforce immutability and communicate the intent of the component.
  • Intersection types combine multiple interfaces when a component logically accepts props from different sources.
  • Provide sensible defaults for optional fields in the function body to keep the interface concise.

Frequently Asked Questions

What's the difference between a union type (A | B) and an intersection type (A & B)?

A union A | B means the value is either type A OR type B. An intersection A & B means the value must have all fields from both A and B. Unions are useful for restricted options; intersections combine multiple requirements.

How do I type a prop that can be a string or a number?

Use a union: value: string | number. Inside the component, check the type with typeof value === 'string' before using string methods.

Can I inherit from multiple interfaces at once?

Yes—interface C extends A, B { } makes C inherit all fields from both A and B, plus any new fields you add.

Should every optional prop have a default value?

Not necessarily—optional props can be undefined inside the component. Provide defaults only if the component relies on a value. Use conditional rendering or the optional chaining operator (?.) for optional props.

What is the difference between readonly fields and const?

readonly is a type-level (compile-time) check preventing reassignment. const is a runtime variable that cannot be reassigned. readonly in interfaces applies to object properties, not variables.

Further Reading