Skip to Content
TypeScript Intermediate: Types That Survive Real Projects
Intermediate
0 likes
Courses
TypeScript Intermediate: Types That Survive Real Projects

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.

Course Home

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; }; }'.
Diagram: ApiResponse is one of three cases; the status property is checked; success has data, error has code and message, loading has no other properties; a new case is caught by a switch with a never check

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 type in one and kind in another.
  • Using a boolean that does not match the fields, like { ok: true; error: string }.
  • Writing default: return "unknown" instead of assertNever. 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
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.