Stop Using Optional Props: Why Discriminated Unions Make Better React Components

Sketch of TypeScript type system as safety net catching a runtime bug

The Friday production bug that TypeScript would have caught

It was a Friday afternoon. We had a DataTable component with an onEdit callback prop. The prop was optional — sensible, because not every table needed edit functionality. The interface looked like this:

interface DataTableProps<T> {
  data: T[]
  mode?: 'view' | 'edit'
  onEdit?: (row: T) => void
}

You can already see the problem. But we couldn't at the time — or rather, TypeScript couldn't, because we hadn't given it enough information to care.

Inside the component, we checked if (mode === 'edit') and rendered an edit button. That button called onEdit(row). The bug: a caller passed mode="edit" and forgot onEdit. TypeScript shrugged. The component rendered the edit button. The user clicked it. onEdit is not a function.

Runtime error. Production. Friday.

That bug changed how I think about props.

The Core Problem with Optional Props

Optional props (prop?: SomeType) tell TypeScript "this might exist, it might not." What they don't express is the relationship between props. In our table above, mode and onEdit aren't independent — they're coupled. mode: 'edit' only makes sense when onEdit is present. The type system had no way to know that, so it couldn't protect us.

This is the failure mode: optional props let you express individual nullability but not conditional dependencies. As components grow, the combinations of optional props explode into invalid states that TypeScript silently permits.

Here is a more extreme version of the same problem, a modal component that handles both creation and editing:

// ❌ The wrong way — optional props hiding invalid states
interface UserModalProps {
  isOpen: boolean
  mode?: 'create' | 'edit'
  userId?: string
  initialData?: {
    name: string
    email: string
  }
  onSubmit: (data: { name: string; email: string }) => void
  onClose: () => void
}

In edit mode, userId and initialData are required. In create mode, they should not exist at all. But this interface says nothing about that. TypeScript will happily compile a caller that passes mode="edit" with no userId. It will also compile a caller that passes userId with mode="create" — a contradiction that means nothing to the type checker.

Every developer who touches this component has to read the implementation to understand which props apply to which mode. The types are not doing their job.

Discriminated Unions: Making Invalid State Unrepresentable

A discriminated union is a union of object types, each with a literal-typed "discriminant" field that lets TypeScript narrow the type based on that field's value. No runtime gymnastics — the narrowing happens at the type level, at compile time.

Here is the same modal rewritten:

// ✅ The right way — discriminated union enforces valid prop combinations
type UserModalProps =
  | {
      mode: 'create'
      isOpen: boolean
      onSubmit: (data: { name: string; email: string }) => void
      onClose: () => void
    }
  | {
      mode: 'edit'
      isOpen: boolean
      userId: string
      initialData: {
        name: string
        email: string
      }
      onSubmit: (data: { name: string; email: string }) => void
      onClose: () => void
    }

Now TypeScript knows: if mode is 'edit', userId and initialData are required and guaranteed to be present. If mode is 'create', they don't exist. A caller that passes mode="edit" without userId gets a compile error. A caller that passes userId with mode="create" gets a compile error. The invalid states are structurally impossible.

Inside the component, narrowing is automatic:

function UserModal(props: UserModalProps) {
  // props.userId does not exist here — TypeScript enforces it
  if (props.mode === 'edit') {
    // TypeScript now knows: props.userId is string, props.initialData is present
    const { userId, initialData } = props
    // ...
  }
}

No runtime guards. No non-null assertions. The discriminant does the work.

Before and After: The DataTable Fix

Back to the Friday bug. Here is the fix:

// ❌ Before — mode and onEdit are silently decoupled
interface DataTableProps<T> {
  data: T[]
  mode?: 'view' | 'edit'
  onEdit?: (row: T) => void
}
// ✅ After — the relationship is enforced at compile time
type DataTableProps<T> =
  | {
      mode: 'view'
      data: T[]
    }
  | {
      mode: 'edit'
      data: T[]
      onEdit: (row: T) => void
    }

With this change, mode="edit" without onEdit is a type error. TypeScript catches it when the developer writes the call site, not when the user clicks the button.

A More Complex Example: Button Variants

This pattern pays bigger dividends as component complexity grows. Consider a button component that can render as a link, an action button, or a submit button — each with different required props:

// ❌ Before — a prop soup with unclear rules
interface ButtonProps {
  children: React.ReactNode
  variant: 'link' | 'action' | 'submit'
  href?: string
  onClick?: () => void
  disabled?: boolean
  loading?: boolean
  type?: 'button' | 'submit' | 'reset'
}

What are the rules here? href only makes sense for variant="link". loading and disabled only apply to action and submit. type only applies to submit. Nobody can tell from the types alone.

// ✅ After — each variant is a self-contained contract
type ButtonProps =
  | {
      variant: 'link'
      children: React.ReactNode
      href: string
    }
  | {
      variant: 'action'
      children: React.ReactNode
      onClick: () => void
      disabled?: boolean
      loading?: boolean
    }
  | {
      variant: 'submit'
      children: React.ReactNode
      disabled?: boolean
      loading?: boolean
      type?: 'button' | 'submit' | 'reset'
    }

Every variant is a complete, self-describing contract. Pass variant="link" without href: compile error. Pass href with variant="action": compile error. The type system is doing the documentation work your comments used to do.

Exhaustiveness Checking

One of the underrated benefits of discriminated unions is exhaustiveness checking with a never assertion. This guarantees that every branch of a switch or if-else is handled, and that adding a new variant to the union forces you to handle it everywhere it's consumed:

function renderButton(props: ButtonProps) {
  switch (props.variant) {
    case 'link':
      return <a href={props.href}>{props.children}</a>
    case 'action':
      return <button onClick={props.onClick}>{props.children}</button>
    case 'submit':
      return <button type={props.type ?? 'submit'}>{props.children}</button>
    default:
      // TypeScript errors here if you add a new variant and forget this switch
      const _exhaustive: never = props
      throw new Error(`Unhandled variant: ${JSON.stringify(_exhaustive)}`)
  }
}

If you add variant: 'icon' to the union later and forget to add a case 'icon' to this switch, TypeScript tells you at compile time. Zero runtime surprises.

Migration Tips

If you have an existing component with messy optional props, here is how I approach the migration:

1. Map out the actual valid states. Look at every call site. What combinations of props actually appear in practice? Those are your union members.

2. Start with the discriminant. Pick a required prop that cleanly separates the modes — usually something like mode, variant, type, or kind. Literal string values work best.

3. Pull required props out of optionality. For each union member, make every prop that is actually required in that mode non-optional. Remove props that do not apply.

4. Update call sites iteratively. TypeScript will tell you which call sites break. Each error is a place where invalid state existed silently before.

5. Add exhaustiveness checks wherever you switch on the discriminant.

One practical note: if you find the union members share a lot of common props, extract them:

type BaseButtonProps = {
  children: React.ReactNode
  disabled?: boolean
}

type ButtonProps =
  | (BaseButtonProps & { variant: 'link'; href: string })
  | (BaseButtonProps & { variant: 'action'; onClick: () => void })
  | (BaseButtonProps & { variant: 'submit'; type?: 'button' | 'submit' | 'reset' })

Intersection types compose well with unions. Keep the discriminant in each member, not in the base.

The Mental Shift

The deeper lesson here is not about TypeScript syntax — it is about what types are for. Types are not documentation. They are not optional hints. They are executable specifications that the compiler enforces on every developer who touches the code.

Optional props are a cop-out. They push the burden of understanding prop relationships out of the type system and into runtime checks, comments, and tribal knowledge. Discriminated unions pull it back where it belongs: into the types themselves, enforced before the code ever runs.

I now reach for discriminated unions as the default whenever I see a component with two or more related optional props. The pattern is almost always the right call. The extra verbosity in the type definition pays for itself the first time TypeScript stops a bad call site from compiling.

That Friday bug was the last runtime error of its kind I shipped. Not because I got more careful — but because I stopped relying on careful and started relying on the compiler.