Skip to content

Typescript Primer

typescriptreact

After years of writing JavaScript in production React apps, adopting TypeScript wasn't about learning a new language — it was about formalizing the contracts I was already keeping in my head. This is a distillation of the patterns I use most frequently, the ones that have saved me from shipping bugs and made refactoring large codebases feel safe.

Discriminated unions for component state

The single most impactful TypeScript pattern in React is modeling component state as a discriminated union rather than a bag of optional fields. This eliminates an entire class of impossible-state bugs.

// Instead of this — where nothing stops you from setting
// data and error simultaneously:
type BadState = {
  isLoading: boolean
  data?: User[]
  error?: string
}

// Model your states explicitly:
type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string }

function UserList() {
  const [state, setState] = useState<AsyncState<User[]>>({ status: 'idle' })

  // TypeScript narrows the type inside each branch —
  // state.data is only accessible when status is 'success'
  switch (state.status) {
    case 'idle':
      return null
    case 'loading':
      return <Spinner />
    case 'success':
      return <ul>{state.data.map(u => <li key={u.id}>{u.name}</li>)}</ul>
    case 'error':
      return <Alert message={state.error} />
  }
}

The compiler narrows the type inside each branch here. To get full exhaustiveness checking — where adding a new state variant flags every switch that doesn't handle it — you need a default branch that assigns to never (e.g., const _exhaustive: never = state). Alternatively, the switch-exhaustiveness-check ESLint rule from typescript-eslint catches missing cases automatically. Either way, that kind of safety compounds across a codebase.

Generic components that preserve type information

When building reusable components, generics let you maintain type relationships between props without forcing consumers to cast or assert anything.

type SelectProps<T> = {
  options: T[]
  value: T
  onChange: (value: T) => void
  getLabel: (option: T) => string
  getKey: (option: T) => string | number
}

function Select<T>({ options, value, onChange, getLabel, getKey }: SelectProps<T>) {
  return (
    <select
      value={String(getKey(value))}
      onChange={(e) => {
        const selected = options.find(o => String(getKey(o)) === e.target.value)
        if (selected) onChange(selected)
      }}
    >
      {options.map(option => (
        <option key={getKey(option)} value={String(getKey(option))}>
          {getLabel(option)}
        </option>
      ))}
    </select>
  )
}

// Usage — TypeScript infers T as Country from the options prop,
// then enforces that value and onChange match:
<Select
  options={countries}
  value={selectedCountry}
  onChange={setSelectedCountry}
  getLabel={(c) => c.name}
  getKey={(c) => c.code}
/>

This scales to data tables, list components, comboboxes — anywhere you need to abstract over a collection of items while keeping the consumer's types intact.

Strict typing for custom hooks

Custom hooks are where loose typing causes the most subtle bugs, because the contract between the hook and its consumers is implicit. Making that contract explicit with TypeScript catches issues at the call site instead of at runtime.

function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === 'undefined') return initialValue
    try {
      const item = window.localStorage.getItem(key)
      return item ? (JSON.parse(item) as T) : initialValue
    } catch {
      return initialValue
    }
  })

  const setValue = useCallback(
    (value: T | ((prev: T) => T)) => {
      setStoredValue(prev => {
        const valueToStore = value instanceof Function ? value(prev) : value
        window.localStorage.setItem(key, JSON.stringify(valueToStore))
        return valueToStore
      })
    },
    [key],
  )

  return [storedValue, setValue] as const
}

// The return type is readonly [T, (value: T | ((prev: T) => T)) => void]
// not (T | Function)[] — as const preserves the tuple structure (and makes it readonly)
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light')

The as const on the return is critical. Without it, TypeScript infers a union array and the destructured values lose their positional types.

Typing component props with polymorphism

For design-system components that need to render as different HTML elements, the pattern of an as prop with proper type forwarding keeps things ergonomic without sacrificing type safety.

type BoxProps<C extends React.ElementType> = {
  as?: C
  children: React.ReactNode
} & Omit<React.ComponentPropsWithoutRef<C>, 'as' | 'children'>

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

// Renders a <section> — TypeScript knows 'href' is not valid here
<Box as="section" className="p-4">Content</Box>

// Renders an <a> — TypeScript allows 'href' because it's valid on anchors
<Box as="a" href="/about">About</Box>

Headless UI uses this same as prop approach internally. Radix took a different path — they replaced as with an asChild boolean prop that uses slot merging, which avoids some of the complexity around prop forwarding and TypeScript inference. Either way, understanding the polymorphic pattern is worth it, because you'll reach for it whenever you build your own primitives.

Type-safe API layers with Zod

Runtime validation and static types should be a single source of truth. Zod schemas give you both, which eliminates the drift between what your API actually returns and what your components expect.

import { z } from 'zod'

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'editor', 'viewer']),
  createdAt: z.string().datetime().transform(s => new Date(s)),
})

type User = z.infer<typeof UserSchema>

async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`)
  const data = await response.json()
  return UserSchema.parse(data)
}

If the backend changes a field name or type, parse throws at the boundary instead of letting bad data propagate into your component tree where it causes a cryptic rendering error three layers deep.

Const assertions and template literal types

These two features together let you build type-safe configuration objects that TypeScript can reason about structurally.

const ROUTES = {
  home: '/',
  profile: '/profile',
  article: '/articles/:slug',
  settings: '/settings',
} as const

type Route = (typeof ROUTES)[keyof typeof ROUTES]
// type Route = "/" | "/profile" | "/articles/:slug" | "/settings"

// Extract route params from the path pattern:
type ExtractParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<Rest>
    : T extends `${string}:${infer Param}`
      ? Param
      : never

type ArticleParams = ExtractParams<typeof ROUTES.article>
// type ArticleParams = "slug"

This is the kind of type-level programming that feels academic until you use it in a router or a form builder — then it saves you from an entire class of typos and missing-parameter bugs.

Type-safe context with strict providers

React Context is one of the most common sources of undefined casts in codebases. The fix is a provider pattern that makes it impossible to consume the context outside the provider boundary.

import { createContext, useContext, useState, useMemo } from 'react'

type AuthContext = {
  user: User
  logout: () => void
  updateProfile: (data: Partial<User>) => Promise<void>
}

const AuthContext = createContext<AuthContext | null>(null)

function useAuth(): AuthContext {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider')
  }
  return context
}

function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null)

  // Only render children once we have a user —
  // this means useAuth() never returns null downstream
  if (!user) return <LoginScreen onLogin={setUser} />

  const value: AuthContext = useMemo(
    () => ({
      user,
      logout: () => setUser(null),
      updateProfile: async (data) => {
        const updated = await api.updateUser(user.id, data)
        setUser(updated)
      },
    }),
    [user]
  )

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

// Consumers never deal with null checks:
function ProfileHeader() {
  const { user, logout } = useAuth()
  return (
    <header>
      <span>{user.name}</span>
      <button onClick={logout}>Sign out</button>
    </header>
  )
}

The null initial value plus the throwing useAuth hook is a deliberate pattern — it turns a runtime mistake (forgetting the provider) into an immediate, debuggable error instead of silent undefined property access.

Typing event handlers and refs

Getting the right event and ref types is one of those things that trips people up more than it should. Here's a reference for the patterns that come up constantly.

function SearchInput() {
  const inputRef = useRef<HTMLInputElement>(null)

  // Typing inline handlers — React provides synthetic event types
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value)
  }

  // For keyboard events, narrow by key
  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') {
      e.preventDefault()
      inputRef.current?.focus()
    }
  }

  // Form submission
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
    const query = formData.get('query') as string
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        ref={inputRef}
        name="query"
        onChange={handleChange}
        onKeyDown={handleKeyDown}
      />
    </form>
  )
}

The key insight: use React.ChangeEvent, React.KeyboardEvent, React.FormEvent etc. — not the native DOM equivalents. And for refs, the generic parameter is the element type (HTMLInputElement, HTMLDivElement, SVGSVGElement).

Conditional props with type narrowing

Sometimes a component's props should change shape based on a variant or mode. Instead of making everything optional, use a discriminated union at the props level to enforce valid combinations.

type ModalProps =
  | {
      variant: 'confirm'
      title: string
      message: string
      onConfirm: () => void
      onCancel: () => void
    }
  | {
      variant: 'alert'
      title: string
      message: string
      onDismiss: () => void
    }
  | {
      variant: 'custom'
      title: string
      children: React.ReactNode
      onClose: () => void
    }

function Modal(props: ModalProps) {
  switch (props.variant) {
    case 'confirm':
      return (
        <Dialog title={props.title}>
          <p>{props.message}</p>
          <footer>
            <button onClick={props.onCancel}>Cancel</button>
            <button onClick={props.onConfirm}>Confirm</button>
          </footer>
        </Dialog>
      )
    case 'alert':
      return (
        <Dialog title={props.title}>
          <p>{props.message}</p>
          <button onClick={props.onDismiss}>OK</button>
        </Dialog>
      )
    case 'custom':
      return (
        <Dialog title={props.title} onClose={props.onClose}>
          {props.children}
        </Dialog>
      )
  }
}

// TypeScript enforces the right props for each variant:
<Modal variant="confirm" title="Delete?" message="This cannot be undone" onConfirm={handleDelete} onCancel={close} />
// Error: 'onConfirm' does not exist on variant 'alert'
<Modal variant="alert" title="Done" message="Saved." onConfirm={noop} />

This is far more maintainable than a flat props type with a dozen optional fields. When you read the code, you immediately know which props belong to which mode.

Type-safe reducers

For complex state logic, useReducer paired with discriminated union actions gives you the same exhaustiveness guarantees as component state unions, but at the action level.

type Todo = { id: string; text: string; done: boolean }

type TodoAction =
  | { type: 'add'; id: string; text: string }
  | { type: 'toggle'; id: string }
  | { type: 'delete'; id: string }
  | { type: 'edit'; id: string; text: string }
  | { type: 'clear_completed' }

function todoReducer(state: Todo[], action: TodoAction): Todo[] {
  switch (action.type) {
    case 'add':
      return [...state, { id: action.id, text: action.text, done: false }]
    case 'toggle':
      return state.map(t => (t.id === action.id ? { ...t, done: !t.done } : t))
    case 'delete':
      return state.filter(t => t.id !== action.id)
    case 'edit':
      return state.map(t => (t.id === action.id ? { ...t, text: action.text } : t))
    case 'clear_completed':
      return state.filter(t => !t.done)
  }
}

function TodoApp() {
  const [todos, dispatch] = useReducer(todoReducer, [])

  // TypeScript knows exactly which payloads are valid:
  dispatch({ type: 'add', id: crypto.randomUUID(), text: 'Write blog post' })
  dispatch({ type: 'toggle', id: '123' })

  // Error: Property 'text' is missing in type '{ type: "add"; id: string; }'
  dispatch({ type: 'add', id: '123' })
}

The discriminated union on TodoAction means you can never dispatch a malformed action. Note that the ID is generated at the call site, not inside the reducer — reducers must be pure functions, and crypto.randomUUID() is a side effect. Add a new action type and — because todoReducer has an explicit Todo[] return type — the compiler will flag the switch for not handling the new case (see the exhaustiveness discussion earlier).

Utility types you should know by heart

These built-in utility types come up so often in React codebases that they're worth internalizing.

// Pick only what a child component needs from a larger type
type UserCardProps = Pick<User, 'name' | 'avatar' | 'role'>

// Omit fields when wrapping a component
type CustomInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> & {
  size: 'sm' | 'md' | 'lg'
}

// Make all fields optional for patch/update operations
type UpdateUserPayload = Partial<Omit<User, 'id' | 'createdAt'>>

// Make specific fields required from an otherwise optional type
type CreateUserPayload = Required<Pick<User, 'name' | 'email'>> & Partial<Pick<User, 'role' | 'avatar'>>

// Extract the resolved type from a promise-returning function
type UserResponse = Awaited<ReturnType<typeof fetchUser>>

// Extract props from an existing component
type ButtonProps = React.ComponentProps<typeof Button>

// Record for lookup maps
type PermissionMap = Record<User['role'], string[]>
const permissions: PermissionMap = {
  admin: ['read', 'write', 'delete'],
  editor: ['read', 'write'],
  viewer: ['read'],
}

The combination of Pick, Omit, Partial, and Required covers about 90% of the type transformations you'll need when adapting types across component boundaries.

Type guards for runtime narrowing

When you're working with data from external sources or union types that can't be narrowed with a simple property check, custom type guards bridge the gap between runtime checks and compile-time safety.

// User-defined type guard — the return type predicate
// tells TypeScript what a truthy return means
function isHTMLElement(node: EventTarget | null): node is HTMLElement {
  return node instanceof HTMLElement
}

function isApiError(error: unknown): error is { code: number; message: string } {
  return (
    typeof error === 'object' &&
    error !== null &&
    'code' in error &&
    'message' in error &&
    typeof (error as Record<string, unknown>).code === 'number'
  )
}

// In practice:
async function handleRequest() {
  try {
    await submitForm()
  } catch (error) {
    if (isApiError(error)) {
      // TypeScript knows error.code and error.message exist here
      if (error.code === 422) {
        showValidationErrors(error.message)
      } else {
        showToast(error.message)
      }
    } else {
      showToast('An unexpected error occurred')
    }
  }
}

// Filtering arrays with type guards preserves the narrowed type
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'rect'; width: number; height: number }

const shapes: Shape[] = [
  { kind: 'circle', radius: 5 },
  { kind: 'rect', width: 10, height: 20 },
]

// .filter doesn't narrow the array type on its own — you need a type guard:
const circles = shapes.filter((s): s is Extract<Shape, { kind: 'circle' }> => s.kind === 'circle')
// circles is now typed as { kind: 'circle'; radius: number }[]

The key syntax is the is keyword in the return type. It's a contract between you and the compiler — you're asserting that your runtime check is sound, and TypeScript will trust you.

Where types aren't the answer

TypeScript is a tool, not a religion. Over the years I've learned where to stop pushing it:

  • Don't type what you don't own. If a third-party library has poor types, wrap it in a thin adapter with your own types rather than wrestling with declare module augmentations.
  • Avoid deep generic nesting. If a type signature takes more than a few seconds to read, it's probably better expressed as two simpler types with a runtime check between them.
  • Use unknown over any. When you genuinely don't know the shape of something, unknown forces you to narrow before using it. any just turns off the compiler.
  • Don't over-type internal code. A private utility function that's called in two places doesn't need the same level of generic abstraction as a public API. Let inference do the work.

TypeScript's value isn't in achieving 100% type coverage — it's in catching the bugs that would otherwise survive code review and make it to production. Focus the rigor where it has the highest leverage: component boundaries, API layers, and shared state.