TypeScript from the ground up

Unions and narrowing

A value that may be one of two things: how to say so with a union, and how the compiler follows your checks until only one possibility is left.

Part 5 of 13

Beginner15 min readBack to Courses

One of these

A union type says a value may be one of several things: string | number is read "text or a number". It is the honest type for a function that accepts both, and it is how TypeScript describes values that arrive from the outside — a form field, a search box, an answer from an API.

What a union refuses is guessing. Inside the function, the value is string | number, and the compiler will not let you use a string method on something that might be a number. That refusal is the feature: the compiler is asking which case this line is for, and the answer is a check you have to write.

Check first, then use

function display(value: string | number): string {
  if (typeof value === "number") {
    return value.toFixed(2);
  }
  return value.toUpperCase();
}

The shape of the check is the point

A typeof check is not just a run-time guard here — the compiler follows it. Inside the if, the value is a number and every number method is available; after the block, the only possibility left is a string. That is narrowing.

Narrowing is why unions are safe to use. The compiler does not trust the annotation, it trusts the check: a branch that has ruled one possibility out is a branch where the other is all that remains.

Missing values, and the two operators for them

undefined joins unions more often than anything else, because optional values are everywhere. value?.trim() stops at the first undefined instead of throwing, and value ?? "none" supplies a fallback for exactly null and undefined.

The pair is worth knowing as a pair: ?. is "and only if it is there", ?? is "or else this". Both keep the compiler informed — the narrowed type follows what the operators guarantee.

Checks other than typeof

typeof splits text, number, boolean and the rest. It cannot tell two object shapes apart, and it reports "object" for an array as well as for a plain object. For those cases the check reads something else: Array.isArray(value) proves a list, and the in operator proves a property is present.

The compiler follows all of them. Inside if (Array.isArray(value)), a string | string[] union is a list, and .join is available; after the branch, only the string remains. The check is ordinary JavaScript that happens to carry proof.

Truthiness is not a type check

if (value) narrows a union by removing null, undefined, 0, "" and false — not just the missing values. For a string | undefined that is usually harmless, but for a number | undefined it quietly changes what the function does with a legitimate zero.

When only the missing case matters, say so: value !== undefined, or value ?? fallback. The narrower check keeps the promise the type made and does not discard a real value.

How narrowing actually works

The compiler keeps two facts about a value: the type it was declared with, and what has been proved on the current path. A union is the list of possibilities; a check removes some of them, and the narrowed type is what remains. The proof holds only where the check dominates the use — inside the if block, in the else, or after an early return that dealt with one case.

This is why moving code can lose a narrowing. Copy the value into another variable, pass it through a function that returns it, or move the check into a callback, and the compiler may no longer be able to carry the proof across. When a narrowed value suddenly widens, look for the point where it stopped being the same expression.

What goes wrong

A method that only one side of the union owns produces an error that lists every member: Property ’toFixed’ does not exist on type ’string | number’. Read it as a question — which member is this method for? — and the missing check answers it.

The subtler mistake is a check that looks complete. typeof null reports "object", so if (typeof value === "object") still lets null through, and the next property read throws. When the union includes null, test for it by name rather than trusting a broad typeof.

Three ways a union stops paying. Casting with as throws away the proof the compiler was building. A non-null assertion ! does the same in one character, and it moves the crash to run time. And || used as a fallback swallows 0 and the empty string; ?? is the operator that means "only when it is missing".

Try these next

  • Write a function that takes string | string[] and joins the list case.
  • Replace if (value) with an explicit undefined check and find an input where the output changes.
  • Narrow a number | null with ?? 0 and with || 0, and compare the results for zero.

Text or number

Type the parameter as a union of text and number, then return the character count for text and the doubled value for a number.

Live Preview
Console output will appear here...

3 left to do

  • The parameter is a union of text and number
  • The number case is separated with a check
  • Text answers with its character count

Start with if (typeof value === "number") — inside that branch the value is a number, and after it, the only thing left is text.

One tag or many

Type the parameter as text or a list of text, then join the list case into one string and return the single string unchanged.

Live Preview
Console output will appear here...

3 left to do

  • The parameter may be text or a list of text
  • The list case is separated with a check
  • The list is joined into one string

Array.isArray(value) narrows the list half; after the branch the only possibility left is string.

Reach for a union before reaching for any. A union says what the possibilities really are and keeps the checks; any throws the whole conversation away.

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What does the compiler allow inside a function whose parameter is string | number?