Skip to content

Frontend

TypeScript Patterns That Actually Pay Off in React Codebases

Not the clever type gymnastics — the handful of patterns that genuinely stop bugs: discriminated unions, satisfies, branded types, and deleting your enums.

3 min read0 views
  • #typescript
  • #react
  • #frontend
  • #patterns
  • #dx

TypeScript has a large surface area and most of it doesn't matter day to day. These are the patterns I actually reach for in React code — each one has caught a real bug, more than once.

1. Discriminated unions instead of optional soup

The default way people type async state:

type State = {
  loading: boolean;
  data?: User[];
  error?: string;
};

This has eight representable combinations and exactly three valid ones. Nothing stops { loading: true, data: [...], error: 'boom' }, and every consumer needs data?.map and a non-null assertion somewhere.

A discriminated union makes invalid states unrepresentable:

type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; message: string };

Now the compiler narrows for you:

function UserList({ state }: { state: State }) {
  switch (state.status) {
    case 'idle':
      return null;
    case 'loading':
      return <Skeleton />;
    case 'error':
      return <ErrorBox message={state.message} />;   // .message exists here
    case 'success':
      return <List items={state.data} />;            // .data exists here, not optional
  }
}

No optional chaining, no assertions. And if you add a 'refetching' variant later, every switch that doesn't handle it becomes a compile error — provided you add an exhaustiveness check:

default: {
  const _exhaustive: never = state;
  throw new Error(`Unhandled status: ${JSON.stringify(_exhaustive)}`);
}

That never assignment is the highest value-per-character TypeScript I know. It converts "I hope I updated every consumer" into a build failure.

2. satisfies instead of a type annotation

Annotating a config object widens it, and you lose the literal types:

// ✗ theme.colors.primary is `string` — the specific value is gone
const theme: Theme = {
  colors: { primary: '#c8fa4b', ink: '#08090c' },
};

satisfies validates against the type without widening:

// ✓ Checked against Theme, but keys and values stay literal
const theme = {
  colors: { primary: '#c8fa4b', ink: '#08090c' },
} satisfies Theme;
 
type ColorName = keyof typeof theme.colors;   // 'primary' | 'ink'

You get the error checking and the precision. This is the single most useful addition to the language for application code in years, and it's ideal for route maps, design tokens and feature flags.

3. Delete your enums

enum is one of the few TypeScript features that emits runtime JavaScript, which makes it awkward with isolatedModules, bundler tree-shaking and erasableSyntaxOnly. A const object does the same job with none of it:

export const Status = {
  Draft: 'draft',
  Published: 'published',
  Archived: 'archived',
} as const;
 
export type Status = (typeof Status)[keyof typeof Status];
// 'draft' | 'published' | 'archived'

Same ergonomics (Status.Published), plus plain string values assign cleanly — which matters when the value comes from an API or goes into a database. Numeric enums are worse still: Status.Draft === 0 means any number type-checks where a Status is expected.

4. Branded types for identifiers

Every ID in an app is a string, and the compiler happily lets you pass the wrong one:

function getPost(postId: string) { /* … */ }
 
getPost(userId);   // compiles fine. Ships a bug.

Branding makes them distinct at compile time and free at runtime:

declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
 
export type UserId = Brand<string, 'UserId'>;
export type PostId = Brand<string, 'PostId'>;
 
export const asUserId = (value: string) => value as UserId;
export const asPostId = (value: string) => value as PostId;
 
getPost(userId);   // ✗ Type 'UserId' is not assignable to 'PostId'

The brand exists only in the type system — at runtime these are ordinary strings. Worth applying to IDs, currency amounts (paise vs rupees), and anything with units.

5. Type props from the DOM, don't invent them

Hand-written prop lists always end up missing something — aria-label, formAction, data-*. Extend the real element type instead:

import type { ComponentPropsWithoutRef } from 'react';
 
type ButtonProps = ComponentPropsWithoutRef<'button'> & {
  variant?: 'solid' | 'outline' | 'ghost';
};
 
export function Button({ variant = 'solid', className, ...rest }: ButtonProps) {
  return <button className={cn(styles[variant], className)} {...rest} />;
}

Everything a <button> accepts now works, correctly typed, forever.

For a component that wraps another component, ComponentProps<typeof Thing> does the same:

type LinkProps = ComponentProps<typeof Link> & { variant?: 'nav' | 'inline' };

6. Narrow at the boundary, trust inside

External data is unknown no matter what the type annotation says. await res.json() is any — an annotation on it is a lie the compiler will happily accept.

Validate once at the edge and derive the type from the validator:

import { z } from 'zod';
 
const postSchema = z.object({
  id: z.string(),
  title: z.string(),
  tags: z.array(z.string()),
  publishedAt: z.coerce.date(),
});
 
export type Post = z.infer<typeof postSchema>;   // single source of truth
 
export async function getPost(slug: string): Promise<Post> {
  const res = await fetch(`/api/posts/${slug}`);
  return postSchema.parse(await res.json());     // throws on drift
}

The value here isn't type safety in the editor — it's that a backend field rename fails loudly at the boundary with a readable error, instead of silently producing undefined three components deep.

7. Generic components without the pain

Typed generically, a list component keeps the relationship between items and the render callback:

type ListProps<T> = {
  items: readonly T[];
  keyOf: (item: T) => string;
  children: (item: T, index: number) => React.ReactNode;
};
 
export function List<T>({ items, keyOf, children }: ListProps<T>) {
  return (
    <ul>
      {items.map((item, i) => (
        <li key={keyOf(item)}>{children(item, i)}</li>
      ))}
    </ul>
  );
}
 
// `post` is fully typed as Post — no annotation needed at the call site
<List items={posts} keyOf={(p) => p.id}>
  {(post) => <PostCard post={post} />}
</List>

readonly T[] is deliberate: it signals the component won't mutate, and it accepts both arrays and as const tuples.

What I skip

Deep conditional type gymnastics. If a type needs a paragraph of explanation, it costs more than it saves. Write the simpler type and a test.

any as a shortcut. Use unknown and narrow. any disables checking silently and spreads through everything it touches.

Over-typing internals. A five-line helper doesn't need a generic constraint. Inference is good — let it work.

The goal isn't maximum type coverage. It's making the wrong thing hard to write. Discriminated unions, satisfies, exhaustiveness checks and validation at the boundary get you most of the way there, and none of them require understanding conditional types.