Site icon Ampersand Tutorials

TypeScript Unions and Type Narrowing (2026)

typescript union types tutorial cover showing 'TypeScript Unions and Type Narrowing (2026)' in bold white lettering on a dark navy grid background.

Quick answer: A TypeScript union type such as string | number means a value can be one of several types. Narrowing is how TypeScript follows your checks — typeof, in, instanceof, equality and discriminated unions — to know which member is present at each point. Use type for unions and aliases, and interface for object shapes you expect to extend.

TypeScript union types and literal types

type Id = string | number;

type Status = "idle" | "loading" | "success" | "error";   // literal union

let state: Status = "idle";
state = "loading";
// state = "done";        // error — not one of the four

Literal unions are one of TypeScript’s most practical features. A status field that can only be one of four strings turns a runtime typo into a compile error, and editors autocomplete the options.

Narrowing TypeScript union types with typeof and in

function format(value: string | number): string {
  if (typeof value === "number") {
    return value.toFixed(2);        // narrows to number here
  }
  return value.trim();              // narrows to string here
}

type Cat = { meow: () => void };
type Dog = { bark: () => void };

function speak(a: Cat | Dog) {
  if ("meow" in a) a.meow();        // the in operator narrows
  else a.bark();
}

You do not annotate the narrowed type; TypeScript works it out from the guard. That is the whole idea of narrowing: ordinary JavaScript checks give the compiler enough information to follow.

Discriminated unions

The most powerful pattern is a shared literal field that identifies each variant. A switch on that field then narrows the whole object.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rect"; w: number; h: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;
    case "square": return s.side ** 2;
    case "rect":   return s.w * s.h;
  }
}

Add a fourth variant and every switch that handles Shape becomes a compile error until you cover the new case. That exhaustiveness check is why discriminated unions are the recommended way to model states in TypeScript.

type or interface?

UseChoose
Union, intersection, tuple, primitive aliastype
Object shape you may extend or mergeinterface
Function typetype
Public API of a class you implementinterface

In practice the two overlap heavily, and a project that picks one convention and sticks to it is fine. The hard rule is that only type can express a union or a tuple, so those always use type.

Assertions and type guards

// a custom guard returns a type predicate
function isString(v: unknown): v is string {
  return typeof v === "string";
}

const values: unknown[] = ["a", 1, true];
const strings = values.filter(isString);   // string[]

// assertion: last resort, no runtime check
const el = document.getElementById("app") as HTMLDivElement;

Prefer a real guard to an assertion. as tells the compiler to trust you and performs no check at runtime, so a wrong assertion moves the failure to production. A type predicate is safer because it pairs the claim with an actual test. Next: modules and namespaces.

To learn TypeScript properly with a mentor, Ampersand Academy teaches web development one-to-one.

TypeScript union types: patterns and pitfalls

Unions model states, and states change. The most useful place for TypeScript union types is any value that is genuinely one of a few alternatives at runtime — a request that is idle, loading, failed or done, a result that is either data or an error, an argument that accepts two shapes. The discipline that makes them pay off is keeping the union closed: every time a new state is added to the code but forgotten in the type, the compiler stops protecting that path. Treat the union as the single source of truth for the states your program can be in, and let each new state force its own updates everywhere the union is used.

Narrowing by exclusion has a subtle failure mode worth knowing. Negated checks narrow well for primitives, but TypeScript only follows a chain of guards when each branch is unambiguous; a check such as a value not being a string still leaves everything else in the union, so the next line may require another guard.

Exhaustiveness checking closes the loop: after a switch over a discriminated union, throw in the default branch with a variable typed as never, and any state you forgot to handle becomes a compile error instead of a silent fallthrough. This one pattern converts runtime surprises into build failures and is the habit that separates TypeScript union types used as documentation from union types used as a guarantee.

Watch for TypeScript union types that collapse unexpectedly. TypeScript reduces a union when one member is assignable to another, so adding a broad type such as any or unknown quietly flattens careful work, and string literals unioned with plain string become plain string. Object literals written inline can also widen: a property annotated loosely accepts any string, losing the literal union you intended. The fix is to annotate at the boundary where the data enters the program and to keep literal unions behind type aliases, so widening has to pass through a declared type rather than creeping in through an unannotated assignment at some deep point in the code.

Distribute conditional types over unions when you are transforming them, because that is the default behaviour and forgetting it produces surprising results. Mapping a union with a conditional type visits each member separately, which is exactly what you want when converting one TypeScript union type of input shapes into a union of output shapes.

Where it hurts is when you expected a single combined object, and the fix is to wrap the type in square brackets to collect the members instead. For TypeScript union types, neither behaviour is better; they are two different operations that look almost identical in the syntax, and knowing which one you asked for saves an hour of staring at a generated type in the editor’s hover panel.

Finally, keep assertions honest. A type assertion tells the compiler to trust you and performs no check at runtime, so a wrong assertion compiles cleanly and fails later in production, which is precisely the bug class union types exist to prevent. Where a real check is possible, prefer a guard function that both tests and narrows, and reserve assertions for the rare boundary where the runtime evidence genuinely exists but the compiler cannot follow it. Used this way, TypeScript union types stop being extra syntax and become the running documentation of every state your application can be in, checked at every branch you write.

Deserializing TypeScript union types safely

Developers regularly parse incoming JSON payloads into TypeScript union types. Network boundaries receive raw unknown data lacking compile-time guarantees. Static type assertions bypass compiler safeguards, exposing applications to runtime exceptions. Parsing requires explicit shape confirmation before narrowing logic executes safely. Defensive architectures treat external payloads with suspicion until verified.

Discriminator validation at system boundaries

External inputs arrive via HTTP endpoints, message queues, or local storage. Assuming payload matches expected variant causes silent failures when critical fields disappear during transmission. Safe deserialization inspects discriminator property first, confirming value exists before checking variant-specific properties. Type predicates provide runtime verification while teaching compiler correct shape without unsafe casts.

type SuccessPayload = { status: "success"; data: string[] };
type FailurePayload = { status: "failure"; code: number };
type ApiResponse = SuccessPayload | FailurePayload;

function isApiResponse(input: unknown): input is ApiResponse {
  if (typeof input !== "object" || input === null) return false;
  if (!("status" in input)) return false;
  const status = (input as { status: unknown }).status;
  return status === "success" || status === "failure";
}

type SuccessPayload = { status: "success"; data: string[] }; type FailurePayload = { status: "failure"; code: number }; type ApiResponse = SuccessPayload | FailurePayload; function isApiResponse(input: unknown): input is ApiResponse { if (typeof input !== "object" || input === null) return false; if (!("status" in input)) return false; const status = (input as { status: unknown }).status; return status === "success" || status === "failure"; }

Validating network boundaries prevents malformed objects masquerading as valid TypeScript union types. Once type predicate confirms presence of discriminator field, downstream code branches safely. Each execution path receives strictly narrowed types without casting. Production services prevent crashes by rejecting mismatched inputs immediately upon arrival, logging discrepancies for diagnostic inspection.

Schema validation libraries automate discriminator checks across complex nested structures. When performance permits, integrating runtime validation libraries ensures runtime objects strictly conform to declared types. Unvalidated payloads undermine static typing, turning guaranteed branch coverage into runtime vulnerability. Always validate discriminator keys before invoking branch logic in critical pathways.

Malformed records cause cascading errors when passed deep into service logic. Catching structure anomalies early localizes troubleshooting efforts to ingress points. Writing custom type predicates keeps bundle footprints minimal compared to heavy external validation libraries. Teams maintain full control over validation performance while upholding strict typing requirements.

Mutually exclusive properties in unions

Object literal assignments enforce strict checking, but TypeScript union types can introduce unexpected flexibility. When union variants share common fields without explicit discriminator, compiler allows unexpected property combinations under specific conditions. Excess property checks run during direct assignment, but intermediate variables bypass this safeguard entirely, permitting invalid configurations to compile.

Handling excess properties with TypeScript union types

Consider scenario where configuration object accepts file path or raw buffer, never both. Modeling this structure requires making non-target fields impossible rather than optional. Optional properties permit callers to supply conflicting values simultaneously, producing ambiguous runtime states that bypass standard compiler warnings. Software bugs thrive inside these ambiguous states.

type FileSource = {
  filePath: string;
  buffer?: never;
};

type BufferSource = {
  filePath?: never;
  buffer: Uint8Array;
};

type InputSource = FileSource | BufferSource;

function processInput(source: InputSource): void {
  if (source.filePath !== undefined) {
    console.log(source.filePath);
  } else {
    console.log(source.buffer.byteLength);
  }
}

type FileSource = { filePath: string; buffer?: never; }; type BufferSource = { filePath?: never; buffer: Uint8Array; }; type InputSource = FileSource | BufferSource; function processInput(source: InputSource): void { if (source.filePath !== undefined) { console.log(source.filePath); } else { console.log(source.buffer.byteLength); } }

Explicit never properties guarantee strict exclusivity across TypeScript union types. Marking unselected properties as optional never prevents callers from providing both properties together. Assigning value to property marked never triggers immediate compilation error, forcing consumers to choose single valid variant cleanly.

This pattern preserves clean interfaces without requiring artificial discriminator keys. Library authors use this technique frequently when designing public APIs. Consumers receive intuitive editor completions while compiler enforces mutual exclusivity reliably. Runtime code remains concise because presence check confirms chosen configuration unambiguously without nested conditions.

Developers constructing form schemas or database mutations benefit heavily from mutually exclusive unions. When mutations accept unique lookup criteria like email address or numerical identifier, mutual exclusivity prevents queries containing conflicting identifiers. Compiler prevents user mistakes before network requests execute, eliminating entire classes of ambiguous query bugs.

Filtering and extracting union members

Utility types Extract and Exclude enable surgical manipulation of TypeScript union types. Large systems define wide event collections representing entire application domains. Specific subsystems require handling subsets of those events. Writing new type aliases manually creates maintenance burdens and invites synchronization errors between domain modules.

Distributive behavior splits TypeScript union types automatically during conditional resolution. Conditional types evaluate each member independently against target constraint, then reconstruct resulting union from matching branches. Understanding distribution mechanics allows developers to extract variants matching shared traits, such as status codes or payload shapes without manual duplication.

Core filtering mechanisms provide reliable operations across complex types:

Combining Extract with template literal types yields expressive filtering capabilities. When event names follow consistent naming conventions, developers filter variants using prefix patterns directly. Refactoring base union automatically updates derived subsets, eliminating duplicate declarations. Static typing scales gracefully across enterprise codebases when transformations derive from single authoritative sources.

Filtering operations maintain full type safety across transformations. Derived subsets preserve exact member types, including variant-specific discriminator values and payload properties. Downstream handlers consume derived types knowing compiler enforces accurate constraints. System architecture remains resilient against schema evolution while minimizing boilerplate declarations across multiple files.

Complex pipelines chain these utility operations to transform payloads progressively. Data processing layers extract input types, transform internal fields, and emit filtered union subsets for persistence layers. Every step remains synchronized with root declarations, guaranteeing that modifying root model forces corresponding updates across entire application lifecycle.

Compiler performance with large unions

Massive TypeScript union types degrade compiler performance significantly. Type-checker evaluates assignability by comparing each member against target structures repeatedly. When union sizes exceed hundreds of members, compilation times spike noticeably and language server latency harms developer productivity during daily editing tasks.

Combinatorial growth happens when developers generate Cartesian products from multiple TypeScript union types. Combining two unions containing ten members each creates hundred distinct possibilities. Nesting three or four unions exponentially multiplies type representations inside compiler memory, causing excessive resource consumption during project builds and test execution.

Architectural strategies reduce compiler pressure and maintain fast feedback loops:

Refactoring wide TypeScript union types into nested record structures restores compiler responsiveness. Lookup tables indexed by string keys map variant handlers directly, reducing type verification complexity from linear progression to constant time evaluation. Editors provide faster completions and continuous integration pipelines finish significantly sooner across development environments.

Profiling tools built into modern TypeScript toolchains identify bottlenecks accurately. Using diagnostic flags reveals source files consuming disproportionate checking time. Isolating wide unions into dedicated compilation units prevents localized complexity from slowing down entire projects. Prudent type design balances expressive domain modeling against real-world compilation efficiency.

Large codebases maintain modularity by splitting monolithic unions along bounded context lines. Instead of centralizing every application event into single mammoth union, subdomains maintain local unions covering relevant operations. Shared core modules expose only generic boundaries, keeping individual module compilation times low and workspace diagnostics instantaneous.

Exhaustiveness checking with assert never

Ensuring total coverage across TypeScript union types requires compile-time verification. When teams add new variants to existing unions, branch logic throughout application must adapt accordingly. Omitting handler branches leads to unexpected runtime behavior or silent processing skips that escape automated test suites and surface in production environments.

Compiler utilizes bottom type never to represent impossible states. Narrowing removes handled members sequentially until no types remain in control flow. Passing remaining value to helper function expecting never argument produces immediate compilation error whenever unhandled member slips through branch checks unnoticed.

type Action = | { type: "create"; payload: string } | { type: "update"; payload: string; id: string } | { type: "delete"; id: string }; function assertUnreachable(x: never): never { throw new Error("Unhandled union member: " + JSON.stringify(x)); } function handleAction(action: Action): void { switch (action.type) { case "create": console.log(action.payload); break; case "update": console.log(action.id, action.payload); break; case "delete": console.log(action.id); break; default: assertUnreachable(action); } }

Dead

code elimination works effectively when downstream consumers inspect TypeScript union types exhaustively. Helper function assertUnreachable throws runtime error as safety net against unexpected runtime values, but primary value occurs during build time. Introducing fourth action variant causes compiler failure right inside default branch, pinpointing exact location requiring update.

Adopting exhaustive checking transforms union maintenance from error-prone discipline into automated safeguard. Refactoring core domain models becomes safe because compiler highlights every incomplete branch immediately. Teams deploy updates confidently, knowing omitted branches cannot reach production environments unnoticed by development tooling.

Combining exhaustive checks with automated continuous integration scripts prevents regressions permanently. Pull requests adding new union variants fail compilation if any switch statement omits corresponding branch handler. Developers receive immediate, actionable guidance directly from build logs, accelerating code review cycles and maintaining architectural integrity across evolving codebases.

Frequently asked questions

What is a union type in TypeScript?

A type that allows a value to be one of several types, written with a vertical bar. For example string or number, or a set of literal values such as a status field.

What is type narrowing?

TypeScript following a runtime check, such as typeof or in, to know which member of a union is present in that branch, so it can allow only valid operations.

What is a discriminated union?

A union of object types that each share a literal tag field, so switching on that field narrows the object to one variant and the compiler checks you handled every case.

Should I use type or interface in TypeScript?

Use type for unions, tuples and primitives, and interface for object shapes you expect other code to extend. Either is fine for plain object shapes if the project is consistent.

What is the difference between a type guard and a type assertion?

A type guard performs a real runtime check and tells the compiler the result. An assertion only tells the compiler to trust you, with no runtime test, so a wrong one fails in production.

Exit mobile version