TypeScript Discriminated Unions Cut 300 Null Checks From My Codebase

typescript dev.to

I was reviewing a pull request on my own codebase when I counted 47 if (data !== null) checks spread across 12 files. Every single one existed because of one type definition I'd written two years earlier.

type ApiResponse<T> = {
  data: T | null;
  error: string | null;
  loading: boolean;
};
Enter fullscreen mode Exit fullscreen mode

This felt reasonable when I wrote it. But it created a problem: TypeScript has no idea whether data and error are logically exclusive. So every callsite had to check both, always.

What I was doing everywhere

Every component that consumed this type looked like this:

function UserProfile({ userId }: { userId: string }) {
  const { data, error, loading } = useUser(userId);

  if (loading) return <Spinner />;
  if (error !== null) return <ErrorMessage message={error} />;
  if (data === null) return <ErrorMessage message="No data returned" />;

  // TypeScript STILL requires the null check above
  return <div>{data.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Three checks. Every time. Without the data === null check, TypeScript would still complain — because the type says data can be null independently of error.

The fix: discriminated union

A discriminated union is a type where a shared literal field narrows the union to a specific case. TypeScript understands these natively.

type ApiResponse<T> =
  | { status: 'loading' }
  | { status: 'error'; error: string }
  | { status: 'success'; data: T };
Enter fullscreen mode Exit fullscreen mode

Now there are three distinct states. When you check status, TypeScript knows exactly which other fields are available.

function UserProfile({ userId }: { userId: string }) {
  const response = useUser(userId);

  if (response.status === 'loading') return <Spinner />;
  if (response.status === 'error') return <ErrorMessage message={response.error} />;

  // response.data is T here — no null check needed
  return <div>{response.data.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Two fewer null checks. TypeScript narrows response.data to T automatically.

The exhaustive check pattern

function assertNever(x: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}

switch (response.status) {
  case 'loading': return <Spinner />;
  case 'error': return <ErrorMessage message={response.error} />;
  case 'success': return render(response.data);
  default: return assertNever(response);
}
Enter fullscreen mode Exit fullscreen mode

If you add a new variant to ApiResponse<T> and forget a case in the switch, TypeScript errors at compile time. The default: return assertNever(response) catches it — never assignment fails when a case is unhandled.

Applied to async state

type AsyncData<T> =
  | { state: 'idle' }
  | { state: 'loading' }
  | { state: 'error'; error: Error; retries: number }
  | { state: 'success'; data: T; fetchedAt: Date };
Enter fullscreen mode Exit fullscreen mode

retries only exists on the error case. fetchedAt only on success. TypeScript enforces that at compile time — you can't access fetchedAt on an error state.

When null is still right

Use null for optional fields that genuinely may not exist: user.middleName: string | null, database nullable columns, optional function parameters.

Use a discriminated union when you have mutually exclusive states — API responses, async loading state, form submission state, auth state. The tell: if you're checking two nullable fields in the same if to figure out which state you're in, you want a discriminated union.

The result

Refactoring those 12 files: approximately 300 lines deleted. More importantly, it's structurally impossible to access data without handling the error case first — the compiler won't allow it.


If you're building a TypeScript SaaS and want these patterns pre-wired — discriminated unions for API responses, typed Stripe webhooks, async state management — they're in the starter kit:

AI SaaS Starter Kit — whoffagents.com

More AI tools → whoffagents.com

Source: dev.to

arrow_back Back to Tutorials