When an application calls an API, the response is not always the same. A request may succeed, fail validation, return an authorization error, or encounter a server problem. Each response can contain different fields.
Handling these cases with loosely defined objects often leads to repeated checks, missing properties, and runtime errors. TypeScript discriminated unions provide a safer way to represent these different response shapes.
They let you define a set of possible types and use a shared property to identify which type you are working with. Once TypeScript knows the specific type, it can check which properties are available and warn you when a case has not been handled.
In this article, we'll build a practical API response model, handle each response type safely, and look at common mistakes to avoid.
1. The Problem with Unstructured API Responses
Suppose an API returns a successful response when a user is found, but returns an error object when something goes wrong.
A successful response might look like this:
{
"success": true,
"user": {
"id": 101,
"name": "Aarav Sharma"
}
}
An error response might look like this:
{
"success": false,
"message": "User not found"
}
These responses have different structures. The success response contains a user, while the error response contains a message.
If both responses are represented by a loosely typed object, developers may accidentally access properties that do not exist.
For example:
type ApiResponse = {
success: boolean;
user?: {
id: number;
name: string;
};
message?: string;
};
function displayResponse(response: ApiResponse): string {
return response.user.name;
}
TypeScript correctly reports an error because user is optional. It may not exist when the request fails.
You could add optional chaining, but that would only prevent one kind of runtime error. It would not guarantee that the response contains a valid user when success is true.
A discriminated union gives each response case its own type and makes the relationship between the status and the available fields explicit.
2. What Is a Discriminated Union?
A discriminated union is a union of object types that share a property with different literal values.
That shared property is called the discriminant. TypeScript checks its value to determine which member of the union is currently being used.
For example:
type ApiResponse =
| {
status: "success";
data: {
id: number;
name: string;
};
}
| {
status: "validation_error";
message: string;
field: string;
}
| {
status: "not_found";
message: string;
};
This type describes three possible outcomes:
A successful response containing data.
A validation error containing a message and field name.
A not-found response containing an error message.
The status property is the discriminant. Its values are string literal types, so TypeScript can distinguish the response variants.
A valid success response looks like this:
const response: ApiResponse = {
status: "success",
data: {
id: 101,
name: "Aarav Sharma"
}
};
A validation error has a different shape:
const validationResponse: ApiResponse = {
status: "validation_error",
message: "Email address is invalid",
field: "email"
};
If you try to create a validation error without its required field property, TypeScript reports a type error.
This helps catch mistakes while writing code instead of waiting for them to appear at runtime.
3. Use Type Narrowing to Handle Each Response
The main benefit of discriminated unions is that TypeScript narrows the type based on the discriminant.
Consider a function that displays a message for each API response:
function handleResponse(response: ApiResponse): string {
switch (response.status) {
case "success":
return `Welcome, ${response.data.name}`;
case "validation_error":
return `${response.field}: ${response.message}`;
case "not_found":
return response.message;
}
}
Inside the success case, TypeScript knows that response.data exists.
Inside the validation_error case, it knows that field and message are available.
Inside the not_found case, it knows that the response contains a message but does not contain success data.
This is called type narrowing. TypeScript uses the condition to reduce the set of possible types to the one that matches the current branch.
You can also use an if statement:
function getUserName(response: ApiResponse): string {
if (response.status === "success") {
return response.data.name;
}
return "User information is unavailable";
}
Both approaches work. A switch statement is often easier to read when an API has several response types.
4. Make TypeScript Check That Every Case Is Handled
A switch statement can become incomplete when an API gains a new response type.
For example, the backend might introduce a rate_limited response. If the frontend does not handle it, the application may display a generic error or behave unexpectedly.
You can use the never type to make TypeScript check whether all known variants have been handled.
First, define a helper:
function assertNever(value: never): never {
throw new Error(`Unexpected response: ${JSON.stringify(value)}`);
}
Then use it in the switch statement:
function handleResponse(response: ApiResponse): string {
switch (response.status) {
case "success":
return `Welcome, ${response.data.name}`;
case "validation_error":
return `${response.field}: ${response.message}`;
case "not_found":
return response.message;
default:
return assertNever(response);
}
}
With the current ApiResponse definition, TypeScript can verify that all variants are covered.
Now add another variant:
type RateLimitedResponse = {
status: "rate_limited";
retryAfterSeconds: number;
};
Include it in the union:
type ApiResponse =
| {
status: "success";
data: {
id: number;
name: string;
};
}
| {
status: "validation_error";
message: string;
field: string;
}
| {
status: "not_found";
message: string;
}
| RateLimitedResponse;
TypeScript now reports an error at the assertNever(response) call because response may still be a rate-limited response.
Add a corresponding case:
case "rate_limited":
return `Try again in ${response.retryAfterSeconds} seconds`;
The error disappears once the new variant is handled.
This pattern is particularly useful in larger applications where API response types are shared across components and services.
5. Model Loading, Success, and Error States in React
Discriminated unions are also useful for managing asynchronous UI state.
A common approach is to maintain several separate state variables:
const [loading, setLoading] = useState(false);
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null);
This can represent impossible combinations. For example, the application could accidentally have loading set to true while also displaying an old error and a user object.
A discriminated union can represent the state as one value instead.
type User = {
id: number;
name: string;
};
type UserState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; user: User }
| { status: "error"; message: string };
The state now has four clearly defined variants.
In a React component, you can use it like this:
import { useState } from "react";
type User = {
id: number;
name: string;
};
type UserState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; user: User }
| { status: "error"; message: string };
function UserProfile() {
const [state, setState] = useState<UserState>({
status: "idle"
});
async function loadUser() {
setState({ status: "loading" });
try {
const response = await fetch("/api/users/101");
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
const user = await response.json() as User;
setState({
status: "success",
user
});
} catch {
setState({
status: "error",
message: "Unable to load the user."
});
}
}
return (
<div>
<button onClick={loadUser}>Load user</button>
{state.status === "loading" && <p>Loading user...</p>}
{state.status === "success" && (
<p>Welcome, {state.user.name}</p>
)}
{state.status === "error" && (
<p>{state.message}</p>
)}
</div>
);
}
export default UserProfile;
The component uses one state variable to represent the current UI state. TypeScript ensures that the user property is accessed only after the state has been narrowed to success.
There is one important limitation in this example: response.json() as User tells TypeScript to treat the parsed data as a User, but it does not validate the actual JSON. Type assertions do not check runtime data.
For production applications, validate API responses when they cross the application boundary, especially when the backend schema may change or the response comes from an external service.
6. Handle API Responses from Unknown JSON Safely
TypeScript types are removed during compilation. They do not automatically validate data received from a server.
Suppose the API is expected to return:
{
"status": "success",
"data": {
"id": 101,
"name": "Aarav Sharma"
}
}
The server might accidentally return a missing name, a string instead of a number for id, or an entirely different structure.
This code does not validate the response:
const result = await response.json() as ApiResponse;
It only tells the compiler to trust the value.
A safer starting point is to treat the parsed response as unknown:
const result: unknown = await response.json();
You can then validate the shape before using it. For a small example, a type guard can check the fields needed by the application:
type User = {
id: number;
name: string;
};
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === "number" &&
typeof candidate.name === "string"
);
}
Use the guard before accessing the user:
async function fetchUser(): Promise<User> {
const response = await fetch("/api/users/101");
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
const result: unknown = await response.json();
if (!isUser(result)) {
throw new Error("The API returned an invalid user response.");
}
return result;
}
This example validates a direct User object. If your API wraps the user inside a response envelope, validate the envelope and its nested data as well.
For complex response structures, a runtime schema validation library can make these checks easier to maintain.
7. Avoid Common Discriminated Union Mistakes
Using a general string instead of literal values
This definition is less useful:
type Response = {
status: string;
message?: string;
};
Because status can be any string, TypeScript cannot use it to distinguish specific response variants reliably.
Prefer literal values such as "success", "error", and "not_found".
Making every property optional
A type containing many optional properties allows combinations that should not exist. A success response might have no data, while an error response might accidentally contain success-only fields.
Define each variant with its required properties instead.
Assuming types validate external data
Discriminated unions provide compile-time checks. They do not guarantee that incoming JSON matches the declared type.
Validate untrusted data before treating it as a trusted application model.
Forgetting to handle newly added variants
Use an exhaustive switch with a never check when every known case must be handled. This helps detect missing branches as the API evolves.
Conclusion
TypeScript discriminated unions make it easier to represent API responses that have different shapes. By using a shared property such as status, you can let TypeScript identify the active response type and restrict access to the fields that belong to it.
They are useful for API error handling, React loading states, authentication flows, payment results, and other situations where an application can be in one of several distinct states.
Combine discriminated unions with exhaustive checks and runtime validation at API boundaries. This gives you clearer code, fewer invalid state combinations, and better protection against unexpected server responses.
Join the conversation! Your thoughts help the community grow.