Mahesh Kakunuri/11 min read/

TypeScript Patterns Every React Developer Should Master

Advanced TypeScript patterns for React applications — from generics and discriminated unions to template literals and type guards.

TypeScriptReactJavaScriptType SafetyBest Practices
Ad Space

TypeScript and React are a powerful combination. But moving beyond basic prop types to advanced patterns can dramatically improve your code quality and developer experience.

Here are the TypeScript patterns I use daily in production React applications.

1. Generic Components

Generics allow components to work with any type while maintaining type safety:

interface 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 — types are inferred automatically
const UserList = () => (
  <List
    items={[
      { id: '1', name: 'Alice' },
      { id: '2', name: 'Bob' },
    ]}
    renderItem={(user) => <span>{user.name}</span>}
    keyExtractor={(user) => user.id}
  />
)

TypeScript infers T as { id: string; name: string } from the usage. No explicit type annotation needed.

2. Discriminated Unions for State Management

Instead of boolean flags, use discriminated unions to represent mutually exclusive states:

type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string }

function useAsync<T>(
  fetcher: () => Promise<T>
): AsyncState<T> & { refetch: () => void } {
  const [state, setState] = useState<AsyncState<T>>({ status: 'idle' })

  const fetch = useCallback(async () => {
    setState({ status: 'loading' })
    try {
      const data = await fetcher()
      setState({ status: 'success', data })
    } catch (error) {
      setState({ status: 'error', error: String(error) })
    }
  }, [fetcher])

  return { ...state, refetch: fetch } as AsyncState<T> & { refetch: () => void }
}

// Usage — exhaustive switch ensures all cases handled
function UserProfile({ userId }: { userId: string }) {
  const state = useAsync(() => fetchUser(userId))

  switch (state.status) {
    case 'idle':
    case 'loading':
      return <Spinner />
    case 'success':
      return <UserCard user={state.data} />
    case 'error':
      return <ErrorBanner message={state.error} />
  }
}

Why this matters: You can't access state.data unless you've checked state.status === 'success'. TypeScript enforces this at compile time.

3. Template Literal Types

Create precise string types from unions:

type Size = 'sm' | 'md' | 'lg'
type Variant = 'primary' | 'secondary' | 'ghost'

// Generates: "primary-sm" | "primary-md" | "primary-lg" | ...
type CompoundVariant = `${Variant}-${Size}`

// More practical: event handlers
type EventName = 'click' | 'focus' | 'blur' | 'change'
type ElementType = 'button' | 'input' | 'select'

type EventHandler = `on${Capitalize<EventName>}${Capitalize<ElementType>}`
// Generates: "onClickButton" | "onFocusButton" | ...

4. The satisfies Operator

Use satisfies to validate types without widening:

const palette = {
  primary: '#d97706',
  secondary: '#6b7280',
  error: '#ef4444',
} satisfies Record<string, `#${string}`>

// Type of palette is still the narrow object type
// But values are validated to match `#${string}` (hex colors)

const color = palette.primary
//    ^? type: "#d97706" (literal, not string)

Without satisfies, using Record<string, string> would widen all values to string.

5. Conditional Types for Props

Create prop types that depend on other props:

type ButtonProps =
  | { variant: 'primary'; children: React.ReactNode }
  | { variant: 'icon'; icon: React.ReactNode; label: string }

function Button(props: ButtonProps) {
  if (props.variant === 'icon') {
    return <button aria-label={props.label}>{props.icon}</button>
  }
  return <button className="bg-chai text-white">{props.children}</button>
}

// Error: Property 'children' does not exist on type '{ variant: 'icon'; ... }'
<Button variant="icon" icon={<FaStar />} children="Click" />

// Correct:
<Button variant="icon" icon={<FaStar />} label="Favorite" />

6. Type Guards for Runtime Safety

interface User {
  id: string
  email: string
  name: string
}

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'email' in value &&
    'name' in value
  )
}

// Usage in API responses
async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`)
  const data = await response.json()

  if (!isUser(data)) {
    throw new Error('Invalid user data received')
  }

  return data // TypeScript knows this is User
}

7. The as const Pattern

Use as const for literal types in arrays and objects:

export const THEMES = [
  { id: 'light', label: 'Light' },
  { id: 'dark', label: 'Dark' },
  { id: 'system', label: 'System' },
] as const

// Type is readonly array of literal objects
type ThemeId = (typeof THEMES)[number]['id']
//    ^? "light" | "dark" | "system"

type ThemeLabel = (typeof THEMES)[number]['label']
//    ^? "Light" | "Dark" | "System"

This is incredibly useful for maintaining a single source of truth for configuration.

8. Extracting Component Props

import { Button } from './Button'

// Get the props type of any component
type ButtonProps = React.ComponentProps<typeof Button>

// Extract specific prop types
type ButtonVariant = ButtonProps['variant']
type ButtonSize = ButtonProps['size']

Putting It All Together

Here's a production component using multiple patterns:

type AsyncButtonProps<T extends 'default' | 'confirm'> = {
  action: T
  onAction: () => Promise<void>
} & (T extends 'confirm'
  ? { confirmMessage: string; confirmText: string }
  : { children: React.ReactNode })

function AsyncButton<T extends 'default' | 'confirm'>(
  props: AsyncButtonProps<T>
) {
  const [state, setState] = useState<AsyncState<void>>({ status: 'idle' })

  const handleClick = async () => {
    setState({ status: 'loading' })
    try {
      await props.onAction()
      setState({ status: 'success', data: undefined })
    } catch (error) {
      setState({ status: 'error', error: String(error) })
    }
  }

  return (
    <button onClick={handleClick} disabled={state.status === 'loading'}>
      {state.status === 'loading' ? 'Processing...' : props.children}
    </button>
  )
}

Conclusion

These TypeScript patterns have transformed how I write React components. The key benefits are:

  • Compile-time safety: Catch errors before they reach production
  • Self-documenting code: Types serve as living documentation
  • Better IDE support: Autocomplete and inline errors speed up development
  • Refactoring confidence: TypeScript catches breaking changes instantly

Start with discriminated unions and generic components — they'll give you the most value for the least complexity. As you get comfortable, incorporate the more advanced patterns.

Ad Space

Related Articles