Union Types and Literal Types

8 min read

A test status is "passed", "failed", or "skipped" — never anything else. An HTTP method is one of seven specific strings. An environment is "dev", "staging", or "production". JavaScript leaves these as plain strings, hoping you'll spell them right. TypeScript lets you describe the exact set of valid values — and refuses to compile typos. This lesson covers union and literal types, the workhorses of QA-friendly type design.

Union types — "this OR that"

A union type uses the | operator and reads as "or." A value of a union type can be any of the listed types.

let id: string | number = "user-42";
id = 42;       // ✅ also valid
id = true;
// ❌ Type 'boolean' is not assignable to type 'string | number'.

Unions are how you describe values that legitimately come in more than one shape — an ID that might be a string or a number, a response that might be the data or null, an option that's either a value or undefined.

Literal types — exact values as types

This is the move that makes TypeScript click for QA work. A literal type isn't just "any string" — it's specifically the string "passed". Combined with unions, you can describe a closed set of allowed values:

type TestStatus = "passed" | "failed" | "skipped";
 
let status: TestStatus = "passed";
status = "failed";        // ✅
status = "broken";
// ❌ Type '"broken"' is not assignable to type
//    '"passed" | "failed" | "skipped"'.

Read that error message. It doesn't say "string is wrong." It tells you the exact values allowed. Typos are caught the moment you write them, and IDE autocomplete suggests the three valid values when you type status =.

This pattern shows up everywhere in QA code:

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type BrowserName = "chromium" | "firefox" | "webkit";
type Priority    = "P1" | "P2" | "P3" | "P4";
type Environment = "dev" | "staging" | "production";
type LogLevel    = "debug" | "info" | "warn" | "error";

Every one of those is a closed set of strings the rest of the codebase can rely on. Many teams reach for enum for this — but a literal-string union is simpler, has no runtime cost, and works better with most tooling.

Mixing types in a union

Unions aren't limited to literal strings. You can mix any types:

type Id = string | number;
type Maybe<T> = T | null;
type Loadable = "loading" | "ready" | Error;

The last one is interesting — "loading", "ready", or an actual Error object. The compiler will force every consumer to handle all three cases.

Narrowing — telling TypeScript which case you're in

Once you have a union, you usually need to act differently per case. The check you do at runtime — typeof, Array.isArray, an if against a known value — also tells the compiler which case you're inside. This is called narrowing.

function formatId(id: string | number): string {
  if (typeof id === "string") {
    return id.toUpperCase();   // ✅ TypeScript knows id is string here
  }
  return id.toString();        // ✅ TypeScript knows id is number here
}

Inside the if, TypeScript narrows id to string. After the if (or in the else branch), it narrows to number. The compiler tracks the type per branch — and if you tried to call a string-only method outside the guard, you'd get a compile error.

The same works on literal-string unions:

function reportStatus(status: "passed" | "failed" | "skipped"): string {
  if (status === "passed") return "✅";
  if (status === "failed") return "❌";
  return "⏭️";   // status is narrowed to "skipped"
}

How narrowing flows

The pattern generalises: every check you do that distinguishes the cases of a union narrows the type inside that branch. It's exactly how if and typeof already work in JavaScript — TypeScript just understands the checks and updates the type in real time.

Why teams pick unions over enums

enum exists in TypeScript and you'll see it in older codebases. For most cases you can replace it with a literal-string union and gain three things:

  1. No runtime cost. A literal-string union compiles to nothing — the strings are just strings. enums compile to a runtime object.
  2. Better interop. API responses arrive as plain strings; literal unions match them directly. With enums you'd convert.
  3. Simpler refactors. Adding a new value is one edit; with enums you also have to think about reverse-mapping and numeric vs string variants.

There are still reasons to use enum (chapter 6 covers them), but for closed sets of strings, prefer the union.

A QA-shaped example

A normaliser that turns whatever shape an API returns into one consistent type:

type RawResult = string | { status: "passed" | "failed"; ms: number };
 
function normalise(raw: RawResult): { status: "passed" | "failed"; ms: number } {
  if (typeof raw === "string") {
    // Older endpoint just returns "OK" or "FAIL"
    return { status: raw === "OK" ? "passed" : "failed", ms: 0 };
  }
  return raw;
}
 
normalise("OK");                                  // { status: "passed", ms: 0 }
normalise({ status: "failed", ms: 1230 });        // { status: "failed", ms: 1230 }
normalise({ status: "broken", ms: 0 });
// ❌ Type '"broken"' is not assignable to type '"passed" | "failed"'.

Every wrong value is caught at the call site. Every right value flows through to a downstream consumer that doesn't have to deal with the legacy string form.

⚠️ Common mistakes

  • Forgetting to narrow before using a union. Calling id.toUpperCase() on a string | number is a compile error — TypeScript knows numbers don't have .toUpperCase(). Wrap in if (typeof id === "string") first. Most "why doesn't this work?" beginner pain on unions is forgetting this step.
  • Using string when a literal union would be safer. A function that takes env: string will accept "stagging" (typo) or "" or "foo". Switching the parameter to env: "dev" | "staging" | "production" makes typos a compile error and gives callers autocomplete. Reach for unions whenever the set of valid values is closed.
  • Adding a new case and forgetting the consumers. When you extend type Status = "passed" | "failed" | "skipped" to include "flaky", every switch and if chain that handled the old three values still compiles — but doesn't handle the new one. The fix is exhaustiveness checks with never (chapter 6 covers these); for now, treat adding a union member as a "find every site that handles this type" task.

🎯 Practice task

Build a typed routing helper. 20-30 minutes.

  1. In your ts-for-qa/src folder, create route.ts.
  2. Declare these literal-string types:
    • type Severity = "critical" | "high" | "medium" | "low"
    • type Channel = "pager" | "slack" | "email" | "dashboard"
  3. Write a function routeAlert(severity: Severity): Channel that returns the right channel per severity (critical → pager, high → slack, medium → email, low → dashboard).
  4. Call it with each severity and console.log the result. Run with npx ts-node src/route.ts.
  5. Try routeAlert("urgent"). Read the compile error — TypeScript names the four valid values.
  6. Add narrowing. Write a function formatId(id: string | number): string that calls id.toUpperCase() for strings and id.toFixed(0) for numbers. Confirm both branches compile and run.
  7. Stretch: make routeAlert's switch exhaustive — add a default branch that returns "dashboard" and try removing "low" from the Severity type. Notice how the compiler tracks the new narrower set everywhere.

The next lesson is the punctuation that ties this chapter together — type aliases (the type keyword you've already used several times) and intersections.

// tip to track lessons you complete and pick up where you left off across devices.