Discriminated Unions for API Responses That Vary
Model success, error and loading as separate shapes with one tag, and let a never check find the case you forgot. After this, the compiler knows which properties exist in each case.
Course content
Sign in to save your progress.
-
Generics Without the Fog
-
Generic Functions That Keep the Input Type
-
Generic Types for Lists, Results and Caches
-
-
-
Types That Transform Other Types
-
Utility Types: Partial, Pick, Omit and Record
-
Mapped Types You Can Actually Read
-
-
-
Working With Code You Didn't Write
-
@types Packages and When DefinitelyTyped Is Wrong
-
Typing fetch JSON Without Lying to Yourself
-
-
-
Classes and Modules in TypeScript
-
Classes With public, private and protected
-
Modules, Exports and Avoiding Circular Types
-
-
-
Strictness That Pays Off
-
unknown vs any and Safe Parsing
-
Discriminated Unions for API Responses That Vary
-
-
- Final Check: TypeScript Intermediate
Skip ahead: If you already tag each case of a union with a literal like status: "ok" and use switch with a never check, skip to "Make the compiler tell you about a new case", then continue.
An API rarely answers in one shape. A request can succeed, fail with a message, or still be loading. A lot of code models that with one object and a bunch of optional fields, and then guesses which fields are there. A discriminated union does it differently. Each case gets its own object type, and one property, the tag, says which case you have. Once the compiler can read the tag, it knows exactly which other properties exist.
The version with optional fields
The type is called ApiResponse, not Response, because Response is already a browser type in the default type libraries and a plain script file would clash with it.
type ApiResponse = {
status: string;
data?: { name: string };
message?: string;
};
function show(response: ApiResponse): string {
return response.data.name;
}
const nonsense: ApiResponse = { status: "success", message: "oops" };
optional2.ts(8,10): error TS18048: 'response.data' is possibly 'undefined'.
The compiler is right to complain about data, but look at nonsense. It claims success and has no data, and the type allows it. Optional fields describe what might be there. They say nothing about which combinations are legal.
One type per case, one tag
type ApiResponse =
| { status: "success"; data: { name: string } }
| { status: "error"; code: number; message: string }
| { status: "loading" };
function show(response: ApiResponse): string {
if (response.status === "success") {
return `Hello, ${response.data.name}`;
}
if (response.status === "error") {
return `Failed (${response.code}): ${response.message}`;
}
return "Loading...";
}
console.log(show({ status: "success", data: { name: "Ana" } }));
console.log(show({ status: "error", code: 404, message: "No such user" }));
console.log(show({ status: "loading" }));
Hello, Ana
Failed (404): No such user
Loading...
status is the discriminant. Inside the first if, response is the success case, so data exists and code does not. Inside the second, it is the error case. What is left after both is the loading case. And a response that claims success with a message and no data is now a compile error.
type ApiResponse =
| { status: "success"; data: { name: string } }
| { status: "error"; code: number; message: string }
| { status: "loading" };
const nonsense: ApiResponse = { status: "success", message: "oops" };
union_err.ts(6,52): error TS2353: Object literal may only specify known properties, and 'message' does not exist in type '{ status: "success"; data: { name: string; }; }'.
Make the compiler tell you about a new case
An if chain quietly falls through when someone adds a fourth case. A switch with a never check does not.
type ApiResponse =
| { status: "success"; data: { name: string } }
| { status: "error"; code: number; message: string }
| { status: "loading" }
| { status: "empty" };
function assertNever(value: never): never {
throw new Error("Unhandled case: " + JSON.stringify(value));
}
function show(response: ApiResponse): string {
switch (response.status) {
case "success":
return `Hello, ${response.data.name}`;
case "error":
return `Failed (${response.code}): ${response.message}`;
case "loading":
return "Loading...";
default:
return assertNever(response);
}
}
exhaustive.ts(20,26): error TS2345: Argument of type '{ status: "empty"; }' is not assignable to parameter of type 'never'.
In the default branch, every handled case has been removed from the type. If all are handled, what is left is never, and assertNever compiles. Here empty was added and not handled, so the leftover is { status: "empty" } and the call is an error that names the missing case. That is the best kind of bug report, because it shows up on the day somebody edits the type and not on the day a customer hits it.
Real data still needs a check
The tag only helps when the data really has it. JSON from a server is unknown until you look.
type ApiResponse =
| { status: "success"; data: { name: string } }
| { status: "error"; code: number; message: string };
function isApiResponse(value: unknown): value is ApiResponse {
if (typeof value !== "object" || value === null || !("status" in value)) {
return false;
}
if (value.status === "success") {
return "data" in value && typeof value.data === "object" && value.data !== null;
}
if (value.status === "error") {
return "code" in value && typeof value.code === "number" && "message" in value;
}
return false;
}
const bodies = [
'{"status":"success","data":{"name":"Ana"}}',
'{"status":"error","code":500,"message":"Server error"}',
'{"status":"ok"}',
];
for (const body of bodies) {
const parsed: unknown = JSON.parse(body);
console.log(isApiResponse(parsed) ? parsed.status : "unknown shape");
}
success
error
unknown shape
The third body has a tag the type does not know, and the guard rejects it. This guard checks that data is an object and message exists, but not their inner types. For a real service, check as deep as you rely on.
⚠ Warning: The tag must be a literal type. If you write status: string, the compiler cannot tell the cases apart and narrowing stops working. Write "success", not string.
Common mistakes
- Using optional fields when the cases are really different shapes.
- Spelling the tag differently in two cases, like
typein one andkindin another. - Using a boolean that does not match the fields, like
{ ok: true; error: string }. - Writing
default: return "unknown"instead ofassertNever. It hides the missing case.
💡 Tip: Name the tag the same in every union in the project. status or kind, pick one. A reader should not have to learn it again for each type.
If you skip this, every API shape becomes an object where any field might be there, and you write if (response.data) and hope. Which of your response types allows a combination that the server never sends?
About
Model success, error and loading as separate shapes with one tag, and let a never check find the case you forgot. After this, the compiler knows which properties exist in each case.
- Duration
- 5 min
- Last update
- 10/17/2026
Comments (0)
Sign in to commentNo comments yet.