Asia/Dhaka
Posts

Effective TypeScript Patterns for React: Advanced Techniques for Safer Components

November 7, 2024
TypeScript has become the default language for modern React development. While most developers know the basics — typing props with an interface, adding a return type here and there — mastering the more advanced patterns is what actually separates a component library that merely compiles from one that guides the engineers consuming it toward correct usage. The difference matters more than it might seem. A component that only "type-checks" still lets you construct invalid states, pass mismatched props, or forget a required field until runtime. A component designed with the patterns below makes those mistakes impossible to write in the first place — the compiler catches them before the code ever ships. Here are five highly effective TypeScript patterns for React, ordered roughly from most broadly applicable to most advanced.
When a component or state machine has multiple distinct phases (loading, success, error), developers often reach for multiple boolean flags. This leads directly to "impossible states" — what happens if isLoading and isError are both true at the same time? Nothing in the type system prevents it, so eventually something in production hits that exact combination. Instead, use discriminated unions. You create a single type with a specific literal property (usually called status or type) that TypeScript uses to narrow down which other fields are available in each branch.
Typescript
// ❌ Bad: Impossible states allow for bugs
type BadFetchState = {
  isLoading: boolean;
  isError: boolean;
  data?: UserData;
  errorMessage?: string;
};

// ✅ Good: Discriminated Unions
type FetchState =
  | { status: 'loading' }
  | { status: 'success'; data: UserData }
  | { status: 'error'; error: Error };

function UserProfile({ state }: { state: FetchState }) {
  if (state.status === 'loading') {
    return <Spinner />;
  }

  if (state.status === 'error') {
    // TypeScript knows `state.error` exists here
    return <Error msg={state.error.message} />;
  }

  // TypeScript guarantees `state.data` exists here. No optional chaining needed!
  return <div>{state.data.name}</div>;
}
This pattern extends naturally beyond data fetching — form state, wizard steps, and any UI with mutually exclusive modes all benefit from being modeled as a union rather than a bag of independent booleans.
When building a custom wrapper around a native HTML element (like a custom Button that wraps <button>), you want developers to be able to pass standard HTML attributes — onClick, disabled, aria-label, type — without you having to manually type out every single one. Use React.ComponentProps to extract the full prop set of native elements or other components.
Typescript
import { ComponentProps } from 'react';

// Extract all native button props
type NativeButtonProps = ComponentProps<'button'>;

// Add our custom props
interface CustomButtonProps extends NativeButtonProps {
  variant?: 'primary' | 'secondary';
  isLoading?: boolean;
}

export function Button({ variant = 'primary', isLoading, children, ...rest }: CustomButtonProps) {
  return (
    <button
      className={`btn-${variant}`}
      disabled={isLoading || rest.disabled}
      {...rest} // Spreads onClick, aria-label, etc.
    >
      {isLoading ? 'Loading...' : children}
    </button>
  );
}
This pattern also works with ComponentProps<typeof SomeComponent>, letting you extract and extend the props of another component in your own library — useful for building thin, typed wrappers around third-party UI kits without re-declaring their entire prop surface.
Sometimes you want a component to look like a Button but act like a Link (an <a> tag). Writing separate components for every HTML-element variation violates DRY and multiplies your maintenance surface. The solution is a polymorphic component using the as prop. This tells TypeScript: "render this component using this specific HTML element, and make sure the props match that specific element."
Typescript
import { ElementType, ComponentPropsWithoutRef } from 'react';

type TextProps<T extends ElementType> = {
  as?: T;
  color?: 'primary' | 'secondary';
} & ComponentPropsWithoutRef<T>;

// Using a generic <T> to track the element type
export function Text<T extends ElementType = 'span'>({
  as,
  color = 'primary',
  children,
  ...rest
}: TextProps<T>) {
  const Component = as || 'span';

  return (
    <Component className={`text-${color}`} {...rest}>
      {children}
    </Component>
  );
}

// Usage:
// Valid: 'href' is allowed because as="a"
<Text as="a" href="https://example.com">Link</Text>

// Invalid: TypeScript throws an error because 'href' is not valid on a 'span'
<Text color="secondary" href="...">Text</Text>
Polymorphic components are especially common in design systems — a single Heading, Text, or Box primitive that can render as any semantic element while keeping full prop validation for whichever element was chosen.
A very common but under-typed pattern is a generic list or table component that needs to stay type-safe across completely different data shapes. Without generics, you end up either duplicating the component per data type or falling back to any.
Typescript
type ListProps<T> = {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
  keyExtractor: (item: T) => string;
};

function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={keyExtractor(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

// Usage — TypeScript infers T as User automatically from the `items` array
<List
  items={users}
  keyExtractor={(user) => user.id}
  renderItem={(user) => <span>{user.name}</span>} // `user` is fully typed as User
/>
The payoff here is that renderItem and keyExtractor are both fully typed based on whatever array you pass in — no manual generic annotation needed at the call site, and no any leaking into the component's public API.
A pattern that's become popular since TypeScript 4.9 is the satisfies operator, which validates an object against a type without widening or erasing its literal types the way an explicit annotation would.
Typescript
type RouteConfig = {
  path: string;
  requiresAuth: boolean;
};

// Without satisfies: the object is widened to RouteConfig,
// losing the literal type of `path`
const badRoute: RouteConfig = { path: '/dashboard', requiresAuth: true };

// With satisfies: TypeScript still checks the shape against RouteConfig,
// but `path` keeps its literal type '/dashboard' for autocomplete elsewhere
const goodRoute = { path: '/dashboard', requiresAuth: true } satisfies RouteConfig;
This is particularly useful for route tables, theme tokens, and configuration objects that are both validated against a shape and consumed elsewhere for their exact literal values (for example, generating a union of valid route paths from the config itself).
These patterns compound. A well-built design system component often combines several at once: a polymorphic as prop, ComponentProps extraction for the underlying element, and a discriminated union for variant-specific props that shouldn't be mixed (a Button that's either an icon-only button with a required aria-label, or a text button with children, but never neither).
Typescript
type IconButtonProps = {
  variant: 'icon';
  icon: ReactNode;
  'aria-label': string; // required — icon-only buttons must be labeled
};

type TextButtonProps = {
  variant: 'text';
  children: ReactNode;
};

type ButtonProps = (IconButtonProps | TextButtonProps) & ComponentProps<'button'>;
This forces every call site to supply exactly the right combination of props for the variant it's using — an icon button without an aria-label simply won't compile, which is a much stronger guarantee than a runtime prop-types warning or a linting rule.
For a solo project, some of this can feel like overkill. But on a team — especially a full-stack team where frontend and backend engineers move between codebases — these patterns pay for themselves quickly:
  • Fewer runtime bugs from impossible states, since the type system rules them out at the source.
  • Self-documenting components. A well-typed prop union tells a new engineer what's valid faster than a README ever could.
  • Safer refactors. Renaming a variant or adding a new required field surfaces every affected call site as a compiler error, not a bug report three sprints later.
  • Better autocomplete and IDE support, which compounds across a large component library used by many teams.

By leveraging discriminated unions, ComponentProps extraction, polymorphic as props, generics, and satisfies, you create React components that aren't just type-safe in a superficial sense — they actively guide the engineers consuming them toward correct usage and make entire categories of bugs unrepresentable. For any full-stack developer or software architect building a component library meant to scale across a team, these patterns are less about TypeScript trivia and more about designing an API surface that fails at compile time instead of in production.
On this page
Hey! Got questions?Ask me anything 👋