TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

How to Model API Errors with Discriminated Unions in TypeScript

Practical insights, clear explanations, and useful ideas for understanding the web.

October 26, 20267 min read5 topics
How to Model API Errors with Discriminated Unions in TypeScript
Explore the details
#TypeScript#discriminated unions#API errors#type safety#fetch
October 26, 20267 min read

How to Model API Errors with Discriminated Unions in TypeScript

A request can fail in several ways: the network may be unavailable, the server may return an error status, or a successful response may not match the shape your application expects. A discriminated union lets TypeScript represent these outcomes explicitly, so callers can handle each known case without pretending unexpected exceptions cannot happen.

This tutorial builds a typed result for an API request, narrows it in consumer code, and uses never to catch unhandled error variants.

Key Takeaways

  • Use a literal discriminant such as ok or kind to let TypeScript narrow a union.
  • Keep expected request outcomes in a result value, and let unexpected exceptions propagate.
  • Validate response data at runtime; a TypeScript type alone does not validate JSON.
  • Use never in an exhaustive switch so adding an error variant reveals unupdated consumers.

Design the result and error variants

A useful result type separates success from failure with an ok property. The error itself has a kind property so consumers can distinguish expected failure categories.

type User = {
  id: string;
  name: string;
};

type ApiError =
  | { kind: "network"; message: string }
  | { kind: "aborted" }
  | { kind: "http"; status: number; message: string }
  | { kind: "validation"; message: string; issues: string[] }
  | { kind: "invalid-response"; message: string };

type Result<T, E> =
  | { ok: true; data: T }
  | { ok: false; error: E };

The literal values (true, false, and each kind string) are the discriminants. After checking one, TypeScript narrows the value to the matching member. Result<T, E> can also be reused for other operations without changing the API error definitions.

These error variants describe outcomes the application can handle deliberately. They do not claim to cover every possible exception. For example, a bug in a decoder or an unexpected failure in application code should not be silently relabeled as an ordinary API error.

Parse and validate untrusted response data

A JSON response starts as untrusted data. Define runtime checks for the parts of the response contract that the application relies on. This example treats a 422 response with a message and string-array issues as the API's validation-error format.

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isUser(value: unknown): value is User {
  return (
    isRecord(value) &&
    typeof value.id === "string" &&
    typeof value.name === "string"
  );
}

function isValidationBody(
  value: unknown,
): value is { message: string; issues: string[] } {
  return (
    isRecord(value) &&
    typeof value.message === "string" &&
    Array.isArray(value.issues) &&
    value.issues.every((issue) => typeof issue === "string")
  );
}

function messageFrom(value: unknown): string | undefined {
  return isRecord(value) && typeof value.message === "string"
    ? value.message
    : undefined;
}

type JsonRead =
  | { kind: "json"; value: unknown }
  | { kind: "invalid-json" };

async function readJson(response: Response): Promise<JsonRead> {
  try {
    return { kind: "json", value: (await response.json()) as unknown };
  } catch (cause) {
    if (cause instanceof SyntaxError) {
      return { kind: "invalid-json" };
    }
    throw cause;
  }
}

readJson converts a JSON syntax error into a value the caller can handle, but rethrows other exceptions. Likewise, isUser checks the runtime shape; the User type by itself would not make a server response trustworthy.

Return expected API failures as a union

The request function below handles fetch rejection, HTTP failures, validation responses, and invalid successful responses. Its try block surrounds only the fetch operation, so exceptions in later code are not accidentally converted into network errors.

async function getUser(
  url: URL,
  signal?: AbortSignal,
): Promise<Result<User, ApiError>> {
  let response: Response;

  try {
    response = await fetch(url, { signal });
  } catch (cause) {
    if (
      typeof DOMException !== "undefined" &&
      cause instanceof DOMException &&
      cause.name === "AbortError"
    ) {
      return { ok: false, error: { kind: "aborted" } };
    }

    if (cause instanceof TypeError) {
      return {
        ok: false,
        error: { kind: "network", message: "The request could not be completed." },
      };
    }

    // Preserve unexpected exceptions instead of presenting them as API failures.
    throw cause;
  }

  const parsed = await readJson(response);
  const body = parsed.kind === "json" ? parsed.value : undefined;

  if (!response.ok) {
    if (response.status === 422 && isValidationBody(body)) {
      return {
        ok: false,
        error: {
          kind: "validation",
          message: body.message,
          issues: body.issues,
        },
      };
    }

    return {
      ok: false,
      error: {
        kind: "http",
        status: response.status,
        message: messageFrom(body) ?? `Request failed with status ${response.status}.`,
      },
    };
  }

  if (parsed.kind === "invalid-json") {
    return {
      ok: false,
      error: { kind: "invalid-response", message: "The server returned invalid JSON." },
    };
  }

  if (!isUser(body)) {
    return {
      ok: false,
      error: { kind: "invalid-response", message: "The response did not match the user format." },
    };
  }

  return { ok: true, data: body };
}

This example assumes the API uses 422 for validation errors and returns the stated body shape. Adjust those checks to match your API contract; other non-success statuses remain HTTP errors. It also expects a JSON user response on success, so an empty response is invalid for this particular operation.

Fetch commonly rejects with a TypeError for network-level failures, but that type is not a perfect network-error identifier in every runtime or request setup. Pass a valid URL and supported options, and do not use a broad catch around the whole function to turn arbitrary exceptions into network failures. If your transport exposes a more precise error contract, use that instead.

Narrow the result in consumer code

Check ok before accessing data or error. Then switch on error.kind to handle each expected failure separately.

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

function describeApiError(error: ApiError): string {
  switch (error.kind) {
    case "network":
      return error.message;
    case "aborted":
      return "The request was cancelled.";
    case "http":
      return `Server error (${error.status}): ${error.message}`;
    case "validation":
      return `${error.message}: ${error.issues.join(", ")}`;
    case "invalid-response":
      return error.message;
    default:
      return assertNever(error);
  }
}

async function showUser(url: URL): Promise<void> {
  try {
    const result = await getUser(url);

    if (result.ok) {
      console.log(`Loaded ${result.data.name}`);
      return;
    }

    console.error(describeApiError(result.error));
  } catch (cause) {
    // Unexpected exceptions still reach the application's error-reporting path.
    console.error("Unexpected failure:", cause);
    throw cause;
  }
}

In describeApiError, TypeScript narrows error in each case. If you later add a new member to ApiError and forget to handle it here, the assertNever(error) call becomes a type error: the remaining value is no longer never.

The outer try in showUser is separate from expected result handling. An application might report an unexpected exception and let a higher-level error boundary decide what to do; the key is not to disguise it as one of the known API failures.

Common Mistakes

  • Trusting a type assertion instead of validating JSON. as User changes what TypeScript believes; it does not check what the server sent.
  • Using a broad catch that returns a network error for everything. That can hide programming errors, decoder bugs, or failures unrelated to connectivity.
  • Treating every non-2xx response as a validation error. Keep validation errors tied to the status and response shape defined by your API.
  • Forgetting cancellation. If callers can pass an AbortSignal, decide whether cancellation is a distinct expected outcome or should be handled elsewhere.
  • Leaving a permissive default branch. Returning a generic message from default can stop the compiler from exposing newly added union members; use an exhaustive never check when appropriate.

Best Practices

  • Keep error variants specific enough to support different caller behavior, but avoid encoding every incidental detail as a new variant.
  • Treat response bodies as unknown until runtime checks establish the shape you need.
  • Define the server's error-response contract explicitly, including which status codes signal validation failures.
  • Keep expected operational failures in the result union and unexpected exceptions on the application's normal exception-reporting path.
  • Add tests for each union member and for malformed or unexpected response bodies. This example is a tutorial implementation, not a substitute for tests against your API contract.

Conclusion

A discriminated result union makes expected API outcomes visible to TypeScript and to the code that consumes them. Validate the response at runtime, narrow on literal discriminants, and use never to catch missing cases. Keep unexpected exceptions distinct so the type models failures honestly.

References