TypeScript in React goes beyond adding types to props. The patterns below solve real problems — component APIs that are impossible to misuse, state that self-documents its own shape, and type inference so precise you stop writing types manually. Here are 12 patterns worth knowing.

1. Discriminated Unions for Component Variants

Instead of a bag of optional props that can conflict, use a discriminated union to make impossible states unrepresentable:

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

function Button(props: ButtonProps) { ... }

Now TypeScript enforces that href is required when variant="link"and forbidden otherwise. No runtime checks needed.

2. ComponentProps for Extending HTML Elements

When building wrapper components, use React.ComponentProps to inherit all native attributes automatically:

interface InputProps extends React.ComponentProps<'input'> {
  label: string
  error?: string
}

function Input({ label, error, ...rest }: InputProps) { ... }

This gives you type, placeholder, disabled, onChange, and every other native input prop for free — without listing them manually.

3. Template Literal Types for Design Tokens

Enforce valid combinations of design tokens at the type level:

type Size = 'sm' | 'md' | 'lg'
type Color = 'blue' | 'purple' | 'cyan'
type TokenKey = `${Color}-${Size}`
// "blue-sm" | "blue-md" | "blue-lg" | "purple-sm" | ...

This pattern is powerful for typed utility class generators or design system token maps.

4. as const for Readonly Configs

Add as const to config objects to get the narrowest possible type and prevent accidental mutation:

const STATUSES = ['idle', 'loading', 'success', 'error'] as const
type Status = typeof STATUSES[number]
// type Status = "idle" | "loading" | "success" | "error"

5. ReturnType and Parameters Utilities

Extract types from existing functions without duplicating them:

async function fetchUser(id: string) {
  return { id, name: 'Alex', role: 'admin' }
}

type User = Awaited<ReturnType<typeof fetchUser>>
// { id: string; name: string; role: string }

6. Generic Components That Infer Their Types

A typed Select component that infers the option type from the options array:

function Select<T extends string>({
  options,
  value,
  onChange,
}: {
  options: readonly T[]
  value: T
  onChange: (value: T) => void
}) { ... }

// Usage — TypeScript infers T = "draft" | "published"
<Select options={['draft', 'published']} value={status} onChange={setStatus} />

7. Satisfies for Config Objects

The satisfies operator validates a value against a type while preserving the narrower inferred type:

const config = {
  theme: 'dark',
  fontSize: 16,
} satisfies Record<string, string | number>
// config.theme is still inferred as 'dark', not string

8. Exclude and Extract for Type Manipulation

type AllEvents = 'click' | 'hover' | 'focus' | 'blur'
type MouseEvents = Extract<AllEvents, 'click' | 'hover'>
// "click" | "hover"
type NonMouseEvents = Exclude<AllEvents, MouseEvents>
// "focus" | "blur"

9. Type Guards for Narrowing

Write type guard functions to narrow types in conditional blocks:

function isError(value: unknown): value is Error {
  return value instanceof Error
}

if (isError(result)) {
  console.log(result.message) // TypeScript knows this is Error
}

10. Readonly for Immutable Props

Mark array and object props as Readonly to prevent accidental mutation:

interface TableProps {
  rows: Readonly<Row[]>
  columns: ReadonlyArray<Column>
}

11. never for Exhaustive Switches

Use never to get a compile-time error when you forget to handle a new enum case:

function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${value}`)
}

switch (status) {
  case 'idle': return <Idle />
  case 'loading': return <Loading />
  case 'success': return <Success />
  case 'error': return <Error />
  default: return assertNever(status) // Error if a case is missing
}

12. Mapped Types for Form State

Derive error and touched state types directly from your form data type:

interface FormData {
  name: string
  email: string
  message: string
}

type FormErrors = Partial<Record<keyof FormData, string>>
type FormTouched = Partial<Record<keyof FormData, boolean>>

Adding a new field to FormData automatically makes it available in errors and touched — no separate type maintenance.


These patterns share a common principle: let the type system do the enforcement so your runtime code does not have to. Every type guard you write is a runtime check you never need. Every discriminated union is a class of bugs that cannot compile. That is the real value of TypeScript in React.