Chapter 19

TypeScript + React: Type-Safe Component Design

TypeScript and React are now synonymous with modern frontend engineering. But simply "using TypeScript" does not guarantee type safety. Many codebases are littered with any, as unknown as X, and // @ts-ignoreโ€”the type system rendered decorative. This chapter examines the intersection of TypeScript and React from an engineering perspective, explaining the rationale behind each design decision.

Designing Props Types: Required, Optional, and Variants

Props are a component's public interface. Well-designed Props types let consumers understand the component contract at a glance and catch entire classes of bugs at compile time.

The Required vs Optional Trade-off

interface ButtonProps {
  children: React.ReactNode;      // required: a button must have content
  onClick: () => void;            // required: the core behavior
  disabled?: boolean;             // optional: defaults to false
  variant?: 'primary' | 'ghost'; // optional: defaults to primary
  className?: string;             // optional: external style overrides
}

Optional props should always have default valuesโ€”not scattered props.variant ?? 'primary' calls throughout the component body. Destructuring with defaults in the function signature is the idiomatic approach:

function Button({
  children,
  onClick,
  disabled = false,
  variant = 'primary',
  className,
}: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`btn btn-${variant} ${className ?? ''}`}
    >
      {children}
    </button>
  );
}

Discriminated Unions: Modeling Component Variants Precisely

When a component has multiple distinct "modes" where the shape of props differs fundamentally between them, discriminated unions are the most precise modeling tool available.

Consider an Alert component: the error type requires an error object, confirm requires a callback pair, and info requires neither:

type AlertProps =
  | {
      type: 'info';
      message: string;
    }
  | {
      type: 'error';
      message: string;
      error: Error;
      onRetry?: () => void;
    }
  | {
      type: 'confirm';
      message: string;
      onConfirm: () => void;
      onCancel: () => void;
    };

function Alert(props: AlertProps) {
  // TypeScript narrows the type precisely in each branch
  if (props.type === 'error') {
    console.error(props.error); // type-safe: props.error is guaranteed to exist
  }
  if (props.type === 'confirm') {
    // props.onConfirm exists; props.error does not
  }
}

The payoff: when a consumer passes type="error", TypeScript requires the error field. Passing error with type="info" is a compile error. This is type-system enforcement replacing runtime defensive checks.

The Rise and Fall of React.FC

Early React + TypeScript tutorials almost universally wrote:

const MyComponent: React.FC<Props> = ({ name }) => {
  return <div>{name}</div>;
};

React.FC (alias for React.FunctionComponent) was considered "correct," but the mainstream community has moved away from it. Here is why.

First: implicit children. In React 17 and earlier, React.FC automatically added children?: ReactNode to the props type. This made the component interface dishonestโ€”a component that should not accept children silently accepted them without complaint. React 18 removed this behavior, but the confusion it introduced persists in codebases.

Second: defaultProps inference issues. React.FC has known defects in type inference when combined with defaultProps, causing default values to fail to narrow types correctly.

Third: no actual benefit. A plain function component expresses every necessary semantic. React.FC adds a layer of wrapping with no practical return.

The modern approach is a plain function declaration, letting TypeScript infer the return type naturally:

// Recommended: concise, honest, unconstrained
function MyComponent({ name }: { name: string }) {
  return <div>{name}</div>;
}

// Or with a separate interface
interface MyComponentProps {
  name: string;
}

function MyComponent({ name }: MyComponentProps) {
  return <div>{name}</div>;
}

Event Handler Types

React's synthetic event system has complete TypeScript coverage. The key is knowing which type to reach for.

// Mouse event: the generic parameter is the DOM element type of the event source
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
  e.preventDefault();
  console.log(e.currentTarget.value); // currentTarget is precisely typed
}

// Input change: the standard pattern for text input handling
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
  const value = e.target.value; // string, no cast needed
}

// Form submission
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
  const form = e.currentTarget;
  const data = new FormData(form);
}

// Keyboard event
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
  if (e.key === 'Enter') {
    // ...
  }
}

A practical tip: when you are unsure of an event handler's type, write it inline first and let TypeScript infer the type, then extract it as a named type if needed.

Generic Components

When a component needs to remain type-safe across different data shapes, generic components are the necessary tool. The canonical example is a list component:

interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string;
  emptyState?: React.ReactNode;
}

function List<T>({ items, renderItem, keyExtractor, emptyState }: ListProps<T>) {
  if (items.length === 0) {
    return <>{emptyState ?? <p>No data available</p>}</>;
  }
  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}

// TypeScript automatically infers T as User
interface User {
  id: string;
  name: string;
}

<List<User>
  items={users}
  keyExtractor={(u) => u.id}
  renderItem={(u) => <span>{u.name}</span>}
/>

One caveat: in .tsx files, a generic arrow function <T>() => ... is ambiguous with JSX tag syntax. The fix is <T,>, <T extends unknown>, or using a function declaration.

Typing Hooks

useState Type Inference

In most cases TypeScript infers the type of useState from the initial value:

const [count, setCount] = useState(0);         // number
const [name, setName] = useState('');          // string
const [user, setUser] = useState<User | null>(null); // explicit annotation required

When the initial value is null or undefined, you must provide the explicit generic argument. Without it, TypeScript infers null as the full type, making setUser(someUser) a compile error.

useRef Overloads

useRef has three overloads covering different use cases:

// Case 1: referencing a DOM element (null initial value, read-only .current)
const inputRef = useRef<HTMLInputElement>(null);
// inputRef.current is HTMLInputElement | null
// Assigning inputRef.current = something is a compile error (read-only)

// Case 2: a mutable imperative value (non-null initial, writable .current)
const timerRef = useRef<number | undefined>(undefined);
// timerRef.current can be freely assigned

// Case 3: no initial value (returns MutableRefObject)
const valueRef = useRef<string>();

Choosing the wrong overload produces confusing type errors. The rule is simple: when referencing a DOM element, always pass null as the initial value.

The as Prop: Polymorphic Components with Full Type Safety

Polymorphic components let consumers specify the root element type while retaining type-safe access to that element's attributes. This pattern is ubiquitous in design systems.

type AsProp<C extends React.ElementType> = {
  as?: C;
};

type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);

type PolymorphicComponentProps<
  C extends React.ElementType,
  Props = {}
> = React.PropsWithChildren<Props & AsProp<C>> &
  Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;

interface TextOwnProps {
  size?: 'sm' | 'md' | 'lg';
  weight?: 'normal' | 'bold';
}

type TextProps<C extends React.ElementType> = PolymorphicComponentProps<C, TextOwnProps>;

function Text<C extends React.ElementType = 'span'>({
  as,
  size = 'md',
  weight = 'normal',
  children,
  ...rest
}: TextProps<C>) {
  const Component = as ?? 'span';
  return (
    <Component className={`text-${size} font-${weight}`} {...rest}>
      {children}
    </Component>
  );
}

// When rendered as <a>, href is a valid, type-checked prop
<Text as="a" href="https://example.com" size="lg">Link text</Text>

// When rendered as <h1>, passing href is a compile error
<Text as="h1" size="lg">Heading text</Text>

The implementation is complex to write once, but the consumer API is seamless and fully type-safe.

ReactNode vs ReactElement vs JSX.Element

These three types are frequently confused, but they have precise semantic differences.

Practical guidance:

// children prop: always ReactNodeโ€”children can be any renderable value
interface Props {
  children: React.ReactNode;
}

// Render prop or renderItem: use ReactElement when the return must be an element
interface Props {
  renderHeader: () => React.ReactElement;
}

// Component function return type: let TypeScript infer it
// If you must annotate, use JSX.Element (the conventional choice)
function MyComponent(): JSX.Element {
  return <div />;
}

TypeScript Support for React 19 Features

React 19 introduces new APIs with full TypeScript type definitions.

The use Hook:

import { use } from 'react';

function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise); // inferred as User, not Promise<User>
  return <div>{user.name}</div>;
}

Server Actions:

// A Server Action is an async function accepting FormData or typed arguments
async function submitForm(formData: FormData): Promise<{ success: boolean }> {
  'use server';
  return { success: true };
}

// Used in client components
<form action={submitForm}>
  <input name="email" type="email" />
  <button type="submit">Submit</button>
</form>

useOptimistic type inference:

const [optimisticMessages, addOptimisticMessage] = useOptimistic(
  messages,  // initial state, typed as Message[]
  (state: Message[], newMessage: Message) => [...state, newMessage]
  // return type automatically inferred as Message[]
);

Common Type Pitfalls

Pitfall 1: e.target vs e.currentTarget. e.target is the element that actually fired the eventโ€”its type is the broad EventTarget. e.currentTarget is the element the handler is attached toโ€”TypeScript knows its precise DOM type. For reading input values, use e.currentTarget.value or cast: (e.target as HTMLInputElement).value.

Pitfall 2: conditional rendering with numbers. The pattern condition && <Component /> renders 0 when condition is the number 0โ€”React renders falsy numbers, not nothing. Type-safe alternatives: condition ? <Component /> : null, or !!count && <Component />.

Pitfall 3: Object.keys return type. Object.keys(obj) returns string[], not (keyof typeof obj)[]. This is intentional in TypeScript (objects may have runtime properties beyond what the type declares). Use (Object.keys(obj) as (keyof typeof obj)[]) when you need the narrower type.

Pitfall 4: ref types for custom components. Function components have no instance, so useRef<MyComponent>(null) does not apply. The correct pattern is to expose an imperative handle via useImperativeHandle and type the ref as React.useRef<{ focus: () => void }>(null).

The value of TypeScript in React is not in eliminating every any. It is in expressing component interfaces as machine-verifiable contracts. When your types accurately describe what a component accepts and what it rejects, team collaboration and long-term maintainability improve substantially.

Rate this chapter
4.6  / 5  (10 ratings)

๐Ÿ’ฌ Comments