Skip to content
Zomer Gregorio

Zomer Gregorio

Software Engineer

Resume
Language
← Blog

TypeScript: How to Build Type-Safe API Clients with Discriminated Unions

· TypeScript · API Design · Full-Stack · Type Safety

Learn how to use TypeScript discriminated unions to model robust API responses, eliminate runtime type checking bugs, and achieve exhaustive type narrowing in full-stack applications.

Stay updated

Get a short note when I publish something new. Your email or browser subscription is stored only to deliver these updates; unsubscribe anytime. No account or tracking profile is required.

A confirmation email is required before notifications begin.

Introduction to Type-Safe API Design

Building robust full-stack applications requires predictable communication boundaries between the client and server. When APIs evolve, client-side code often lags behind, leading to runtime exceptions due to unhandled payload shapes, missing fields, or unexpected error structures. While standard interfaces and generic types provide basic safety, they fall short when dealing with polymorphic payloads where the shape of the data depends directly on a discriminant status property.

Discriminated unions provide a powerful pattern for modeling these dynamic API contracts. By leveraging TypeScript's control flow analysis and type narrowing, engineers can write concise, resilient client code that guarantees every possible response branch is handled explicitly at compile time.

Modeling Polymorphic Responses

Consider a standard HTTP API that returns either a successful resource payload or a structured error payload. Using a single interface with optional properties for both data and error states creates a poor developer experience and allows invalid object states.

interface ApiResponse<T> {
  data?: T;
  error?: {
    code: string;
    message: string;
  };
  status: 'success' | 'error';
}

This approach permits impossible runtime states, such as an object containing both data and error, or neither. Instead, we can model this boundary using a discriminated union of distinct object types, bound by a common literal property acting as the discriminant.

type ApiResult<T> = SuccessResult<T> | ErrorResult;
 
interface SuccessResult<T> {
  status: 'success';
  data: T;
  statusCode: number;
}
 
interface ErrorResult {
  status: 'error';
  error: {
    code: string;
    message: string;
    details?: Record<string, unknown>;
  };
  statusCode: number;
}

In this model, the status property serves as the discriminator. TypeScript inspects this literal type during control flow analysis to narrow the broader union type into its constituent members.

Implementing Exhaustive Type Narrowing

When consuming ApiResult<T>, developers must handle both success and error branches safely. Using standard if statements or switch blocks allows TypeScript to narrow the payload type automatically.

function handleResponse<T>(result: ApiResult<T>): T {
  if (result.status === 'success') {
    // TypeScript knows `result` is SuccessResult<T> here
    return result.data;
  }
 
  // TypeScript knows `result` is ErrorResult here
  throw new Error(`API Error [${result.error.code}]: ${result.error.message}`);
}

To ensure future-proofing—such as adding a 'pending' or 'rate_limited' status later—we can enforce exhaustive checking using a helper function that evaluates against the never type.

function assertNever(x: never): never {
  throw new Error(`Unexpected object: ${JSON.stringify(x)}`);
}
 
function processResponse<T>(result: ApiResult<T>) {
  switch (result.status) {
    case 'success':
      return result.data;
    case 'error':
      return result.error.message;
    default:
      return assertNever(result);
  }
}

If a new status variant is added to ApiResult without updating the switch statement, the TypeScript compiler will fail with a type error because the default branch receives a type other than never.

Integrating with Fetch and Async Clients

Applying discriminated unions to real-world networking code requires bridging the gap between untrusted network inputs (which are inherently unknown at runtime) and our strict compile-time types. A production-ready API client should validate payloads before returning them.

async function typedFetch<T>(url: string): Promise<ApiResult<T>> {
  try {
    const response = await fetch(url);
    const json = await response.json();
 
    if (!response.ok) {
      return {
        status: 'error',
        statusCode: response.status,
        error: {
          code: json.code ?? 'UNKNOWN_ERROR',
          message: json.message ?? 'An unknown error occurred',
        },
      };
    }
 
    return {
      status: 'success',
      statusCode: response.status,
      data: json as T,
    };
  } catch (err) {
    return {
      status: 'error',
      statusCode: 500,
      error: {
        code: 'NETWORK_ERROR',
        message: err instanceof Error ? err.message : 'Network request failed',
      },
    };
  }
}

While type assertions like json as T are common, robust architectures often integrate runtime validation libraries like Zod or Valibot to parse the json unknown payload directly against a schema before constructing the SuccessResult.

Performance and Maintenance Tradeoffs

Designing API clients around discriminated unions incurs certain architectural considerations:

  • Payload Overhead: Explicit status tags slightly increase wire size, though this is negligible for JSON payloads.
  • Boilerplate: Writing comprehensive type guards and union definitions requires upfront engineering effort compared to loose typing or any.
  • Refactoring Velocity: When backend contracts change, updating the union definition immediately highlights all client-side consumer sites that require adjustment.

By embracing discriminated unions for network boundaries, teams eliminate entire classes of runtime errors and establish clear, self-documenting contracts across the full-stack architecture.