T
Developertypescriptapierror-handling

TypeScript Discriminated Unions for API Error Modeling

Use discriminated unions to enforce strict error handling in APIs and prevent UI misrepresentation of response types.

TechHowAI Team10 min read

Model API errors with TypeScript unions

id: hook

You want the UI to reflect the exact error type returned by the API. TypeScript's discriminated unions make this possible by forcing the compiler to track error shapes. For example, if your API returns a 400 with a message and code, you can model this as:

ts
type ApiError = {
  code: string;
  message: string;
};

type ApiResponse<T> = {
  success: boolean;
  data: T | null;
  error: ApiError | null;
};

This ensures the UI code must handle error as a ApiError when present. You can also model nested errors or multiple error types by extending the union. For instance, if the API returns 401 or 403 with different messages, you can add:

ts
type AuthError = {
  code: 'UNAUTHENTICATED' | 'FORBIDDEN';
  message: string;
};

Then, your union becomes ApiError | AuthError, and the compiler will enforce that each branch handles the correct type.

If your API returns errors in a non-standard format, or if error types are rare, you might skip unions. But if errors are frequent or nested, unions make the code safer and more maintainable. Always test your error handling with real API responses to catch shape mismatches early.

API returns error with status and message

id: use-case

When an API returns an error, it often includes a status code and a message string. For example:

json
{
  "status": 404,
  "message": "Not found"
}

This structure is common, but it’s easy for a UI to misinterpret the response if the type isn’t properly modeled. TypeScript’s discriminated unions help prevent this by ensuring the UI code knows exactly what to expect.

If the API also returns success responses, you’ll need to model both shapes. For example:

ts
type ApiResponse<T> = {
  status: 200;
  data: T;
} | {
  status: number;
  message: string;
};

This way, the UI can safely check response.status and act accordingly. If the status is 200, it knows to render the data. If it’s anything else, it knows to display the message.

You can use json-formatter to inspect the structure of API responses and json-compare to verify that your TypeScript types match the actual data.

If the API never returns errors, or if errors are rare and simple, you might skip this approach. But if errors are frequent or complex, modeling them with a discriminated union ensures your UI code is safe and readable.

If you’re unsure how to structure the union, start by writing a type for the success case and then add a branch for the error case. This keeps your types focused and avoids unnecessary complexity.

Define union type for error responses

id: walkthrough

Define a Response union that includes a success boolean and an error object with a tag field. For example:

ts
type Response<T> = {
  success: true;
  data: T;
} | {
  success: false;
  error: {
    tag: 'bad_request' | 'not_found' | 'server_error';
    message: string;
  };
};

Use a switch on the tag field to handle different error types. This ensures the UI can't lie about the error type, as TypeScript will enforce the correct handling.

ts
function handleResponse<T>(res: Response<T>) {
  if (res.success) {
    console.log('Success:', res.data);
  } else {
    switch (res.error.tag) {
      case 'bad_request':
        console.error('Bad request:', res.error.message);
        break;
      case 'not_found':
        console.warn('Not found:', res.error.message);
        break;
      case 'server_error':
        console.error('Server error:', res.error.message);
        break;
      default:
        console.error('Unknown error:', res.error.tag);
    }
  }
}

This pattern ensures that all error types are explicitly handled, reducing runtime bugs. Avoid this if your API rarely returns errors or if the error types are too dynamic to model.

Flow of error type checking

This flowchart models how TypeScript's discriminated unions enforce error visibility. When the API returns an error, the response includes a status and message, but the real power is in the error object's shape. By defining a union type like SuccessResponse | ErrorResponse, you ensure the UI can only access properties that exist in the response. For example, if the API returns a 404, the error object might have a code and details field, and the UI must handle that shape.

If you skip unions and just use any, the UI can lie about the response type. That’s why tools like JSON Formatter and JSON Compare help catch shape mismatches early. Always define your union types before writing the UI code.

Common mistakes with union types

Q: What's the most common mistake when using discriminated unions for API errors?

A: Using a non-unique tag. For example, using code: '404' as the tag for a NotFoundError and code: '500' for a ServerError is fine, but if you use type: 'error' for all error types, the compiler can't distinguish between them.

Q: How do I avoid missing cases in a union type?

A: Always use a switch or if/else with exhaustive checks. If you have a union like ErrorType = 'not_found' | 'server_error' | 'invalid_token', you must handle each case explicitly. TypeScript will throw an error if you forget a case.

Q: What if my API returns a generic error shape?

A: Model it as a base Error type with a code and message, then extend it with more specific types. For example:

ts
type Error = {
  code: string;
  message: string;
};

type NotFoundError = Error & { code: 'not_found' };
type ServerError = Error & { code: 'server_error' };

This way, you can still differentiate between error types at runtime.

Q: Should I use string or enum for the tag?

A: Use string for flexibility, especially if your API allows dynamic error codes. enum is more restrictive and harder to maintain if the API changes. For example:

ts
type ErrorTag = 'not_found' | 'server_error' | 'invalid_token';

Q: How do I handle nested unions?

A: Flatten them if possible. If you have a UserError that contains another union, like UserError = { type: 'invalid' | 'missing' }, you can still use a switch to handle each case.

Q: What if the API doesn't return detailed error types?

A: Skip using a discriminated union. Instead, use a single Error type with a code and message. This avoids unnecessary complexity.

Skip when errors are simple or rare

If your API returns only a few error types, like 404 or 500, and they don’t vary much in structure, skip the union type. It adds unnecessary complexity. For example, if all errors have the same shape with a code and message, a single Error type is sufficient.

If errors are rare, like a 1% occurrence, the overhead of union types doesn’t justify the benefit. You’re not gaining much from type narrowing in most code paths. Use a simple Error type and handle the rare cases with a fallback.

If your error responses are always JSON and you’re using a tool like JSON Formatter to inspect them, you can rely on runtime checks. TypeScript unions are best when you need to enforce type safety across multiple error types.

For APIs that return a mix of success and error responses, use a union type to model the full response shape. This ensures the UI can’t lie about the type — it will always know whether it’s handling a success or an error.

If you’re using a tool like JSON Compare to validate responses, you can test that your error types match the expected structure. This helps catch issues early, even if you’re not using TypeScript unions.

Q: When should I use a union type for errors?

A: Use a union type when you have multiple distinct error types with different structures. For example, if you have 400 errors with validation details, 401 with auth details, and 500 with server details, a union type ensures the right type is used in each case.

Q: What if I don’t need to handle errors in the UI?

A: If the UI doesn’t need to differentiate between error types, a single Error type is enough. You can still use a union type if you want to enforce that all errors have a consistent shape, but it’s not required.

Q: How do I handle nested errors in TypeScript with discriminated unions?
A: Use nested unions. For example, if an API returns a 400 with a nestedError field, define a NestedError type and include it in your main Error union. This ensures TypeScript correctly narrows the type when accessing nested properties.

Q: What if the API returns different error structures for different status codes?
A: Define separate union types for each status code. For instance, a 400 might have a validation field, while a 401 has a token field. Combine them into a single Error union to model all possible error shapes.

Q: How do I ensure the UI can't lie about error types?
A: Model the API's error responses with precise TypeScript types. When you fetch an error, TypeScript will enforce that you handle all possible error shapes, preventing the UI from assuming a specific error type.

Q: What tool can help format or compare JSON responses for error modeling?
A: Use JSON Formatter to structure error responses and JSON Compare to verify that your TypeScript types match the API's actual output.

Q: Should I use a single union type for all errors or multiple?
A: Use a single union type if the API consistently returns errors with a common structure. Use multiple types if the error structures vary significantly, as this improves type safety and readability.

Union types enforce error visibility

id: conclusion

Use discriminated unions to model API errors so the UI cannot lie about response types. When you define a union type with a kind field, TypeScript can narrow the type based on the value of that field, ensuring that error handling code is type-safe and avoids runtime errors.

For example, if your API returns a 400 with a message and a 401 with a token field, you can define a union type like:

ts
type ApiError = {
  kind: 'bad_request';
  message: string;
} | {
  kind: 'unauthorized';
  token: string;
};

This ensures that when you receive an error, you can safely access the correct fields without runtime checks. It also makes your code more maintainable and less error-prone.

If your API returns errors with varying structures, unions are essential. However, if errors are simple or rare, you might skip unions and use a generic error object instead. This avoids unnecessary complexity in most cases.

For a visual guide, here's a flowchart showing how TypeScript narrows types based on kind:

Put what you learned into practice — no signup required.