What a union type is
Most variables hold one kind of value, but some data legitimately comes in more than one form: an ID might be the string "abc-123" or the number 42. A union type lists the allowed types separated by |, which reads as "or":
let id: string | number = "abc-123";
id = 42; // fine: number is part of the union
id = true; // error: boolean is not in the union
The compiler reports the last line as Type 'boolean' is not assignable to type 'string | number'. Unions are checked at compile time only. They are erased from the JavaScript that tsc writes, so nothing stops a wrongly typed value from arriving at run time, for example from parsed JSON. Even with the error above, tsc still writes the JavaScript, and the program runs and holds true in id.
The trap: only shared operations are allowed
While a variable's type is the whole union, you can use only operations that are valid for every member. Strings have .length and numbers do not:
function describe(x: string | number): string {
return "length is " + x.length;
}
error TS2339: Property 'length' does not exist on type 'string | number'.
Property 'length' does not exist on type 'number'.
The second line names the member that causes the problem. Read it as "prove which type you have first". If you ignore the error and run the JavaScript anyway, describe(42) gives "length is undefined". That is the bug the compiler was protecting you from.
Narrowing with typeof
The proof is an ordinary if. The typeof operator returns the run-time type of a value as a string, such as "string", "number" or "boolean". TypeScript understands checks of the form typeof x === "string" and narrows the type of x inside the branch:
if (typeof x === "string") {
// here x is a string, so .length is allowed
} else {
// only number is left, so x * 2 is allowed
}
The else needs no second check. Once string is ruled out, one member remains, and TypeScript narrows x to number by itself. With three members you can chain checks, and the last else gets whatever is left:
function f(x: string | number | boolean) {
if (typeof x === "string") return "s";
else if (typeof x === "number") return "n";
else return x ? "yes" : "no";
}
console.log(f("a"), f(1), f(true));
s n yes
Gotchas
- Compare with a string. Write
typeof x === "string", with quotes. Without them,stringis a type name, not a value, and the compiler reports an error. - Narrowing is not conversion. It only tells the compiler what you have proved. It never changes the value. Likewise, a cast such as
x as stringchecks nothing at run time. typeofis coarse. Fornulland for arrays it returns"object", so it can't tell those two apart. For unions of strings, numbers and booleans it is exactly the right tool.- Narrowing lasts within the branch. After the
if/elseends, the variable has its full union type again unless every branch has returned. - Methods shared by all members are fine. Both strings and numbers have
toString(), sox.toString()compiles on astring | numberwithout narrowing.
The lesson's exercise
The task is to write describe(x), where x is a string | number, returning a string: the text "string of length " plus the length for a string, and "number doubled to " plus the doubled value for a number. Then call it with "Algo" and with 21 and print both results.
- Declare the function with the parameter typed
string | numberand the return typestring. The union is what lets one function accept both kinds of argument. - Open an
ifthat teststypeof x === "string". Inside it,xis astring. - Return the string message using
x.length. This compiled only because of the narrowing. The+operator joins the text and the number, converting the number to text. - Add an
elsethat returns"number doubled to "followed byx * 2. The multiplication is legal because onlynumberremains. - Call and print. Pass
"Algo"todescribeand log the result, then do the same with21, so both branches run.
function describe(x: string | number): string {
if (typeof x === "string") {
return "string of length " + x.length;
} else {
return "number doubled to " + x * 2;
}
}
console.log(describe("Algo"));
console.log(describe(21));
The output is:
string of length 4
number doubled to 42
In the second return, * binds tighter than +, so x * 2 is computed first and the result is joined to the text.
Key takeaways
- A union type
A | Ballows a value of either type, and the check happens at compile time only. - On a union you may use only operations that are valid for every member.
- An
ifwithtypeofnarrows the union inside each branch, and theelsenarrows to whatever remains. - Narrowing changes what the compiler knows, not the value itself.