There's a phase every React + TypeScript project goes through where the types stop helping and start fighting back. Generic interfaces with five extends. as any showing up in PRs. Wrapper components whose only job is to convince the compiler that the props are valid. The types feel like the obstacle, not the safety net.

Almost always, the issue is patterns — using the wrong shape for the job. Once you know the half-dozen patterns that scale, TypeScript with React becomes invisible. You stop noticing it most of the time, and when you do notice it, it's usually catching a real bug at compile time.

This article is the practical short list. The patterns I reach for, in order of how often they earn their keep.

Pattern 1: Discriminated Unions For Variants

A Button with three variants — primary, ghost, link — where one of them needs an extra prop:

TypeScript
type ButtonProps =
  | { variant: 'primary' | 'ghost'; href?: never }
  | { variant: 'link'; href: string };

function Button(props: ButtonProps) {
  if (props.variant === 'link') {
    return <a href={props.href}>{/* ... */}</a>;
  }
  return <button>{/* ... */}</button>;
}

<Button variant="link"> requires href. <Button variant="primary" href="..."> is a compile error. The compiler enforces what would otherwise be a runtime assertion or, worse, a bug nobody noticed.

This is the single most useful TypeScript pattern in React. Use it for anything where one prop's type depends on another. The "discriminator" is just a string field the compiler can branch on; once you've branched, the rest of the type narrows automatically.

Pattern 2: ComponentProps For Forwarding

When you wrap a native element, don't redeclare its props — extend them:

TypeScript
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: 'primary' | 'ghost';
};

function Button({ variant = 'primary', className, ...rest }: ButtonProps) {
  return (
    <button
      className={cn(buttonStyles({ variant }), className)}
      {...rest}
    />
  );
}

Consumers get autocomplete for onClick, disabled, type, aria-*, every standard button attribute — without you typing one of them out. The same pattern works for InputHTMLAttributes, AnchorHTMLAttributes, HTMLAttributes (for div), and others.

For wrapping another component, use ComponentProps:

TypeScript
type IconButtonProps = React.ComponentProps<typeof Button> & {
  icon: React.ReactNode;
};

IconButton now accepts everything Button does, plus icon. If Button's props change, IconButton's props update automatically.

Pattern 3: Generic Components

A Select that returns the actual type of the chosen option, not just a string:

TypeScript
type SelectProps<T> = {
  options: T[];
  value: T;
  onChange: (next: T) => void;
  getLabel: (option: T) => string;
};

function Select<T>({ options, value, onChange, getLabel }: SelectProps<T>) {
  return (
    <select
      value={getLabel(value)}
      onChange={(e) => {
        const next = options.find(o => getLabel(o) === e.target.value);
        if (next) onChange(next);
      }}
    >
      {options.map(o => (
        <option key={getLabel(o)} value={getLabel(o)}>{getLabel(o)}</option>
      ))}
    </select>
  );
}
TSX
type Country = { code: string; name: string };
const countries: Country[] = [...];

<Select<Country>
  options={countries}
  value={selected}
  onChange={(c) => console.log(c.code)}    // c is fully typed as Country
  getLabel={(c) => c.name}
/>

The component works for any data shape. The consumer gets full type safety on the value and the change handler. No casting, no any.

Generic components feel intimidating the first time, but they're how the best component libraries (TanStack Table, react-hook-form, react-select) get their ergonomics. Once you've written one, the rest are easy.

Pattern 4: Polymorphic Components — The "as" Prop

A Box (or Text, or Link) that can render as different HTML elements without losing type safety on the props of whichever one you picked:

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

type PolymorphicProps<C extends React.ElementType, P = {}> =
  AsProp<C> & P & Omit<React.ComponentPropsWithoutRef<C>, keyof (AsProp<C> & P)>;

function Box<C extends React.ElementType = 'div'>({
  as,
  children,
  ...rest
}: PolymorphicProps<C, { children?: React.ReactNode }>) {
  const Component = as || 'div';
  return <Component {...rest}>{children}</Component>;
}

<Box>default div</Box>;
<Box as="section" aria-label="Hero">section</Box>;
<Box as="a" href="/foo">link</Box>;       // href required because "a"

This is the fiddliest pattern in this article. Most teams reach for libraries that ship it (Radix's Slot, MUI's component prop). But understanding it unlocks a lot — most "I want my Button to be a link" requests are this pattern in disguise.

A four-card grid showing the patterns: discriminated union, ComponentProps forwarding, generic component, polymorphic as prop. Each card shows a tiny code example and a one-line description of what bug it prevents.
Four patterns. Each one prevents a class of bug at compile time.

Pattern 5: Event Types You'll Actually Need

The event types in React's typings are unfriendly until you know the names. The short list:

TypeScript
// Form events
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
  e.target.value;       // typed string
}

function onSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
}

// Click events
function onClick(e: React.MouseEvent<HTMLButtonElement>) {
  e.currentTarget;      // the button
}

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

// Focus events
function onBlur(e: React.FocusEvent<HTMLInputElement>) {
  e.relatedTarget;      // the next focused element, if any
}

The shape is always React.SomethingEvent<HTMLElementType>. The element type narrows currentTarget and target so you don't have to cast.

When you forward a handler to a child component, use React.ComponentProps:

TypeScript
type Props = {
  onSelect: React.ComponentProps<'select'>['onChange'];
};

This way you don't have to remember the event type — the compiler infers it from the underlying element.

Pattern 6: useState Initialisers And Setter Types

Two common mistakes that bite even seasoned TypeScript users.

Initialising state to null without telling the compiler what it'll become:

TypeScript
const [user, setUser] = useState(null);  // type is null forever
setUser({ id: 1 });                       // ❌ error, can't assign object to null

// fix:
const [user, setUser] = useState<User | null>(null);

The setter that takes a function:

TypeScript
const [count, setCount] = useState(0);
setCount(c => c + 1);    // c is correctly typed as number, no annotation needed

But once the state is a union, the function form needs a hint:

TypeScript
const [theme, setTheme] = useState<'light' | 'dark'>('light');
setTheme((t) => t === 'light' ? 'dark' : 'light');     // works

If the result of the function isn't a literal narrow enough, you'll get an error. as 'light' | 'dark' casts as a last resort, but rewriting to be type-safe is usually one extra check.

Pattern 7: Useful Utility Types

You don't need to memorise the whole utility-type catalogue. Five carry their weight:

  • Partial<T> — every property optional. Useful for "patch" objects.
  • Pick<T, K> — narrow to a subset of fields. Useful for "what does this component actually need?"
  • Omit<T, K> — remove fields. Useful when wrapping components and overriding a prop.
  • ReturnType<typeof fn> — infer the return of a function. Useful for typing what a custom hook returns.
  • Awaited<T> — unwrap a promise. Useful for typing async results.
TypeScript
function useUser() {
  const [user, setUser] = useState<User | null>(null);
  return { user, setUser };
}

type UserHook = ReturnType<typeof useUser>;       // { user: User|null; setUser: ... }

The "when in doubt, infer" rule applies. If TypeScript can derive a type from your code, let it — explicit types are for boundaries (props, public functions, API responses), not for things the compiler can already see.

A Few Habits That Don't Help

The other half of the discipline:

  • Don't use React.FC or React.FunctionComponent. It implicitly adds children and makes generics awkward. Plain function declarations with explicit prop types are clearer:
    TypeScript
    function Button(props: ButtonProps) { ... }   // ✅
    const Button: React.FC<ButtonProps> = (props) => ...   // ❌ avoid
  • Don't any your way out of a problem. If the type is hard to express, the design is usually trying to tell you something. Reach for unknown if you genuinely need an opaque value.
  • Don't redeclare HTML element props. React.ButtonHTMLAttributes<HTMLButtonElement> is shorter than the list of things you'd type.
  • Don't write generic types you don't need. Most components don't need generics. Reach for them when there's a real dependency between the input and output type.
  • Don't fight strict: true. It catches real bugs. The pain in the first week is repaid for the next two years.

Type-First, Not Type-Last

A small habit shift: write the type before the implementation, when you can. For a new component, sketch the props interface first. For a new hook, write its return type. The implementation often becomes shorter and simpler — because the type forces you to make the interface honest before you start solving the problem.

TypeScript
// before any implementation
type UseUploadResult =
  | { state: 'idle';                              start: (f: File) => void }
  | { state: 'uploading'; progress: number;       cancel: () => void       }
  | { state: 'done';     url: string;             reset: () => void        }
  | { state: 'error';    error: string;           retry: () => void        };

function useUpload(): UseUploadResult { /* ... */ }

Now the consumer's code is provably correct: in the 'uploading' branch, progress exists and cancel is callable; in the 'idle' branch, only start is. The hook's behaviour is encoded in the type. Bugs that try to access url from the idle state never compile.

The One-Sentence Mental Model

TypeScript with React earns its keep when types describe what's possible rather than what's allowed in general. Discriminated unions for variants, ComponentProps for forwarding, generics for relationships between input and output — these patterns turn the type system from a tax into a tool that catches bugs you didn't have to think about. Most of the time, when types feel painful, the fix is one of the patterns above.