Union Types
A union says a value is one of several types. That is only useful if the checker can find out *which* one — so the real subject of this lesson is narrowing, and the discriminant that makes narrowing possible.
When is string | number actually useful, and why does my code still not compile after I have checked the type?
A type in the checker's type environment denoting the union of its members' value sets, plus — and this is the half people forget — a flow-sensitive refinement of that type maintained per program point. The union itself answers "what could this be"; only the refinement answers "what is it here", and without the second the first is a value you can hold and cannot use.
An operation on a value of type A | B is well-typed only if it is defined for every member of the union. The checker may treat the value as A alone at a program point only where its flow analysis proves no B reaches that point, and the proof must come from a construct the checker models — a typeof test, a comparison against a literal discriminant field, a user-declared type predicate. A test the checker does not model narrows nothing, no matter how obviously correct it is to a reader.
Key points
- A union widens the set of values and therefore narrows the set of operations: only what every member supports is available unrefined.
- Narrowing is a flow-sensitive data-flow analysis over the CFG, not a property of the declaration — the same identifier has different types at different program points.
- TypeScript unions are untagged: no runtime representation is added, so narrowing must recover the member from something already observable.
- ML-family sums are tagged by construction, which is what makes them always narrowable and always exhaustiveness-checkable.
- A discriminant field of literal type is how you buy the tagged behaviour inside an untagged system;
kind: stringbuys nothing. - Narrowing on a mutable property is discarded across any call the checker cannot see through, because the object may have been mutated.
A union is a set of possibilities, not a supertype
Writing string | number does not create a type that has the members of both. It creates a type whose values are the values of string together with the values of number, which means the only operations it can offer are the ones defined for *both*. Since almost nothing is defined for both, the union starts out nearly useless. That is not a flaw; it is the correct answer to the question that was asked.
Compare with an intersection, which is the opposite construction: A & B offers the members of both because a value must satisfy both. Unions widen the value set and narrow the operation set; intersections narrow the value set and widen the operation set. Getting this backwards is the single most common misreading of [[intersection-types]], and the arithmetic in [[algebraic-data-types]] explains why the two behave as mirror images.
So the design question a union raises is immediate: how does the program ever get back to a single member? Every answer to that is called narrowing, and a union whose members cannot be told apart at runtime cannot be narrowed at all.
1function f(x: string | number) {2 x.toFixed(2) // error: Property "toFixed" does not exist on type "string | number"3 return String(x) // fine: String accepts both4}The error is not the checker being difficult. toFixed genuinely does not exist on half the values this parameter accepts, and the union is the only place that fact is written down.
Narrowing is what makes a union usable
var property at all. Neither behaviour is specified as a language guarantee you can rely on across versions.Narrowing is a data-flow analysis in the frontend, not a syntactic rule. The checker walks the control-flow graph, and at each edge out of a condition it applies a transfer function that removes members the condition rules out. On the true edge of typeof x === "string" the type of x becomes string; on the false edge it becomes number. This is the same machinery as [[constant-propagation]], applied to a lattice of types instead of a lattice of values — which is why it inherits the same limitation: it is only as good as the facts it can carry across a merge point.
The practical consequence is that narrowing evaporates at exactly the places engineers expect it to persist. A narrowed *local* survives until something reassigns it. A narrowed *property* survives only until something the checker cannot see through — a function call, a closure invocation, an await — because the checker must then assume the object was mutated. Complaints about the checker "forgetting" what was just proved are almost always this case, and they are correct: the checker did forget, deliberately, because it could not prove otherwise.
Read it asOne declaration, one symbol, three types. That is what "flow-sensitive" means, and it is why the type of an expression in such a language cannot be computed from the AST node alone — it needs the node *and* the point in the control-flow graph, which is why [[annotated-ast]] is not the last representation the frontend builds.
Untagged unions and the discriminant problem
TypeScript's unions are untagged: string | number is not a wrapper around a value, it is a claim about a value that already exists. Nothing is allocated, nothing is boxed, and there is no field in memory that says which member this is. Narrowing therefore has to recover the member from something already observable — the runtime tag typeof reports, a class identity instanceof reports, or the value of a field you put there yourself.
ML-family languages take the opposite route. In OCaml, Haskell or Rust a sum type is *tagged by construction*: Ok(v) and Err(e) are different constructors, the tag is part of the representation, and there is no way to produce a value of the type without choosing one. You never have to discover which member you have, because the constructor recorded it.
This is a genuine design trade rather than one design being better. Untagged unions can describe types that already exist in a language you do not control — a JSON payload, a DOM event, a legacy API that returns a string on success and a number on failure — with zero runtime representation and zero migration. Tagged sums cannot describe those without a conversion step, but they are always narrowable, which is what makes [[exhaustiveness-checking]] possible at all.
The practical rule falls out of that: if you are designing the data, give it a discriminant. If you are describing someone else's data, you get whatever tag it already had, and if it has none you must invent a parse step — which is exactly the argument of parse-don't-validate in Backend Engineering.
| Property | Untagged union (TypeScript) | Tagged sum (ML, Rust, Haskell) |
|---|---|---|
| Runtime representationimplementation | None. The value is whatever it already was. | A tag plus a payload, chosen at construction. |
| How you find the member | Recover it from an observable property — typeof, instanceof, a discriminant field. | Read the tag. It is always there. |
| Can describe foreign data | Yes — that is the point. It is a claim about existing values. | Only after a conversion step that constructs the tag. |
| Exhaustiveness checkable | Only if a discriminant exists and the checker models the test. | Always. The constructor set is closed by the declaration. |
A | A collapses | Yes. The union is a set; duplicates vanish and are unrecoverable. | No. Left(x) and Right(x) stay distinct even at the same payload type. |
| Cost of adding a member | Every unrefined use may start failing to type-check. | Every non-wildcard match becomes an error — see [[exhaustiveness-checking]]. |
Discriminated unions: giving an untagged union a tag
switch is a specific TypeScript feature keyed on a property whose type is a union of unit (literal) types, present since 2.0 and extended to multiple discriminant properties later. Flow, Kotlin sealed classes and Scala 3 enums all reach the same place by different rules — Kotlin and Scala use the *declared* sealed hierarchy rather than a field value, so they need no discriminant field in the data at all.A discriminated (or tagged) union in TypeScript is the deliberate recovery of the ML property inside an untagged system. Every member gets a field of a singleton literal type — kind: "circle" — and the checker special-cases comparison against that field: testing it narrows the whole object, not just the field. This is not a general inference result; it is a rule the checker implements for exactly this shape, because the shape is worth the special case.
Once the discriminant exists, everything else in this module becomes available: [[pattern-matching]] has something to match on, [[exhaustiveness-checking]] has a closed set to enumerate, and the error you get from adding a variant lands on every incomplete switch instead of nowhere. Without it you have a union that is technically well-typed and practically inert.
The discriminant should be a literal type, not a string. kind: string narrows nothing — every member is compatible with every test — and the resulting code type-checks perfectly while doing the wrong thing, which is the worst outcome the type system can produce.
1type Shape =2 | { kind: "circle"; r: number }3 | { kind: "rect"; w: number; h: number }4 5function area(s: Shape): number {6 switch (s.kind) {7 case "circle": return Math.PI * s.r ** 2 // s is the circle member here8 case "rect": return s.w * s.h // s is the rect member here9 }10}11 12// Without the tag, nothing narrows:13type Bad = { r: number } | { w: number; h: number }14function badArea(s: Bad) {15 if ("r" in s) return s.r // the `in` operator narrows, but only by accident of the shape16 return s.w * s.h // fragile: add a member with an `r` and this silently changes meaning17}The in operator does narrow, and it is the standard escape when you cannot add a tag. It is fragile because it is structural: the moment two members share a property name, the test that used to discriminate stops discriminating and nothing tells you.
How it works
The steps, in the order the compiler takes them.
- The checker builds the union type by taking the members, normalising them (flattening nested unions, removing duplicates and subsumed members) and interning the result so that later comparisons are cheap.
- Each use site is typed against the union: a member access or call is accepted only if it resolves in every member, and the result type is the union of the per-member results.
- Narrowing runs as a forward data-flow analysis over the control-flow graph. Each modelled condition contributes a transfer function that maps the incoming type to a refined type on the true edge and a differently refined type on the false edge.
- At a merge point the analysis takes the union of the incoming refinements, which is why a variable narrowed in one branch is un-narrowed after the
if. - Assignment to the variable resets its refinement to the declared type, filtered by the assigned type. A call resets refinements of properties reachable from anything the callee could hold.
- For a discriminated union the checker special-cases equality against a property whose type is a union of literal types: it filters members by that property, which narrows the object rather than only the field.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A function accepts
string | numberand every call site works, until one passes a number and the string branch was never written — the value is silently stringified and a report shows[object Object]or a concatenated number where a sum was expected. - A narrowed property is used after an
awaitand the checker rejects it with "possibly undefined" even though the reader can see it was just checked — the analysis dropped the refinement across the suspension point and the engineer reaches for a non-null assertion, converting a compile error into a production crash. - A union member is added and nothing fails to compile because every use site was already
any-typed at the boundary, so the new case reaches production and falls through to a default branch that returns a wrong-but-plausible value. - A discriminant is typed
stringinstead of a literal, so every test compares equal-typed strings, no narrowing happens, and the resulting code type-checks while accessing fields that are not present on the actual value. - Two members of an untagged union share a property name, an
incheck that used to discriminate silently stops discriminating, and the wrong branch runs with no diagnostic anywhere.
When it helps
- Describing data you did not design: JSON payloads, event objects, third-party API responses that genuinely return different shapes under different conditions.
- Making an illegal state unrepresentable in a language with no sum types — a union of literal-tagged object types is the closest available construction.
- Modelling a result that is either a value or an error without introducing exceptions, which keeps the failure path in the signature where a reviewer can see it.
- Narrowing an over-broad type at a boundary once, so that the rest of the program works with a specific type instead of re-checking everywhere.
When it hurts
- Wide unions of unrelated types used as a parameter type. Every call site type-checks and every body has to handle every case, so the union becomes an untyped parameter with extra steps.
- Unions over structurally similar members with no discriminant: the checker cannot tell them apart, so neither can your code, and
inchecks degrade as the shapes converge. - Very large unions — hundreds of literal members — make checking quadratic in the worst case and turn a single mistyped call into a page of diagnostic output that names every member.
What it costs
Every one of these is paid by something.
- Untagged unions cost nothing at runtime and pay for it in narrowability: with no tag in the representation, every refinement must be recovered from an observable property, and where none exists the union cannot be used at all.
- Flow-sensitive narrowing buys ergonomics — no explicit unwrapping — and pays in checker complexity and in surprise: the type of an expression now depends on its position, so error messages must explain a program point rather than a declaration, and refinements silently disappear at merge points and across calls.
- A discriminant field buys narrowing and exhaustiveness and pays a byte or a pointer in every value, plus the discipline of keeping the tag in sync with the payload — a value whose tag lies is worse than one with no tag, because everything downstream believes it.
- Normalising unions (deduplicating and collapsing subsumed members) buys fast comparison and pays with information loss:
A | AbecomesAand cannot be recovered, so a union cannot be used to model two distinct things that happen to share a representation. That is what a newtype is for — see[[structural-vs-nominal]].
What else you could do
What a different compiler or language does instead, and when that is better.
- A tagged sum type (
enumin Rust,datain Haskell,sealed interfacein Kotlin or Java) makes the tag part of the value, so narrowing never needs to be recovered and exhaustiveness is decidable by construction — better whenever you own the data. See[[algebraic-data-types]]. - Subtype polymorphism replaces the union with a common interface and virtual dispatch: adding a case then requires no change at any use site, at the cost of no longer being able to write an operation that must consider all cases in one place. This is the expression-problem trade, and it goes the other way from sums.
- An untyped
anyorobjectparameter with runtime validation at the boundary throws away the static information entirely and pays for it once, at a parse step, which is the correct choice when the shape is genuinely unknown until runtime. - Overloaded declarations — one signature per case — keep the call sites precise where a union would blur them, at the cost of an implementation signature that has to reconcile them by hand.
See it for yourself
The flag, dump or tool that shows you this directly.
- Hover in any TypeScript-aware editor shows the *narrowed* type at that position, not the declared one. Hovering the same identifier in two branches is the fastest way to see flow analysis working or not working.
tsc --noEmit --explainFileswill not help here, but a deliberateconst _check: never = xat a point where you believe the union is empty will make the checker print the members it thinks remain.tsc --generateTrace traceDiremits a profile of checker work; a union that has grown pathologically large shows up there as a hotcheckExpressionon one node.- For Rust and OCaml,
rustc -Z unpretty=hir(nightly) andocamlc -dtypedtreeprint the tree with the resolved constructor at each match arm, which shows the tag rather than a refinement — the structural difference between the two designs is visible in the dump.
Plausible wrong readings
Stated the way a confident engineer states them.
- "
string | numbermeans it can do both string and number things." It can do neither, unrefined. That description belongs toA & B, which is the opposite construction. - "The checker forgot my check, so it is broken." It discarded a refinement it could no longer justify — almost always because a call, a closure or a reassignment could have changed the value between the check and the use. The fix is to bind the narrowed value to a
const, not to assert. - "A union is a supertype of its members." It is a supertype in the assignability sense, and that is precisely why it offers fewer operations, not more. Reading it as a common base class predicts the wrong error messages.
- "Adding a member to a union is a safe, additive change." It is a breaking change for every consumer that exhaustively handled the old set, which is the same versioning problem as adding an enum value to a public API.
Misconceptions
The claim, and what is actually true.
boolean narrows nothing; the same helper declared to return x is Circle narrows everything — and the checker takes that declaration on trust rather than verifying it.Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
A union type says a value is one of several types. Unrefined, you may only do things that work for all of them — which is usually nothing. The way back to a usable type is called narrowing: a test the compiler understands, at a specific point in the program, that rules some members out. If you are designing the data, put a small tag field on each member so the test is trivial and reliable.
practical
Two habits remove most union pain. First, always give a union you own a literal-typed discriminant (kind: "a" | "b"), and switch on it — narrowing then works, and exhaustiveness checking becomes available. Second, when narrowing keeps evaporating, bind the narrowed value: const c = s; if (c.kind === "circle") { ... } on a const never loses its refinement, whereas a mutable property loses it at the next call. If you catch yourself writing a non-null assertion or an as cast to get past narrowing, that is the checker telling you it could not prove something — go and find out what.
advanced
The deep difference between an untagged union and a tagged sum is who owns the tag. Untagged unions are a *descriptive* construct: they can be written about data the language did not create, which is what makes TypeScript able to type a decade of existing JavaScript. Tagged sums are a *prescriptive* construct: they create the data, so they can guarantee properties of it. The consequences run all the way down. Untagged unions cannot distinguish two members with the same representation, cannot be enumerated at runtime, and normalise A | A to A irreversibly. Tagged sums can do all three, and pay a tag word for it. When someone proposes adding "real" unions to a tagged language, or exhaustive matching to an untagged one, this is the wall they hit.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
const.tsc and of every erasing transpiler (Babel, esbuild, swc): the union exists only in the checker and nothing is emitted for it. It is false of languages whose unions are tagged sums, where the tag is a real field with a real size — and false of C-style untagged union, which shares storage but carries no type information at all and is a different construct with the same name.[[structural-vs-nominal]].If you were asked this in an interview
- What can you do with a value of type
string | numberbefore you narrow it, and why is that the right answer rather than a limitation? - A colleague says the checker "forgot" a type guard after an
await. What actually happened, and what are the two ways to fix it? - You are adding a third variant to a union that is part of a published package. What breaks for consumers, and what would you do about it?