Union and Literal Types in TypeScript: One Value, Several Possibilities
We cover 'either this or that' union types, literal types for fixed sets of values, why literals widen inside objects, a first look at as const, and why unions need narrowing.
Version note: The code in this post targets TypeScript 7.0 and Node 26.
Predict First
In the previous post we mentioned that type aliases can also express unions. This post is all about them. First, look at this code and guess: how many lines error, and which ones?
type Status = "idle" | "loading" | "done";
let current: Status = "idle";
current = "loading";
current = "finished";
function formatId(id: string | number) {
return id.toUpperCase();
}
A hint: the values on the right of Status look like ordinary strings, but here they stand as parts of a type. We'll give the answer in two pieces, in the relevant sections.
Unions: "Either This or That"
Real-world values don't always fit into a single type. An ID sometimes arrives as a number, sometimes as a string; a lookup result carries either data or an error. TypeScript describes these cases with unions:
function printId(id: string | number) {
console.log(`ID: ${id}`);
}
printId(101);
printId("a-101");
// Error: Argument of type 'boolean' is not assignable to parameter of type 'string | number'.
printId(true);
Read the vertical bar as "or": string | number means "string or number." Each part of a union is called a member. This function accepts either of the two types; if a third type shows up, the compiler objects.
We've actually seen a union before: with strictNullChecks on, the way to say "this value may be missing and be null" was to write string | null. With the type aliases from the previous post you can also give unions names, and members can even be object types:
type Id = string | number;
type ApiResult =
| { data: string[] }
| { error: string };
When you split a long union across lines, it's a common habit to put a | in front of the first line too; TypeScript allows it, and every member lines up neatly underneath.
What Can You Do with a Union?
Here's the first part of the answer to the opening question. The id.toUpperCase() line inside formatId errors:
error TS2339: Property 'toUpperCase' does not exist on type 'string | number'.
Property 'toUpperCase' does not exist on type 'number'.
The second line of the message matters: the problem is on the number side. id may be a string, but it may also be a number, and numbers have no toUpperCase method. On a union value, TypeScript only allows operations that are valid for every member of the union:
function describeId(id: string | number) {
return id.toString(); // exists on both string and number
}
Think of it this way: either a cat or a dog is about to walk through the door, and you don't know which. You can't tell it to bark, because it might be a cat. But you can safely expect something both can do, like walk. A union widens the possibilities for the value; in exchange, it narrows what you can do with the value to what all the possibilities have in common. Keeping both of these in mind together is the key to working with unions.
Literal Types: The Value Itself as a Type
In the post on inference we took a quick look at the difference between let and const:
let mode = "dark"; // its type: string
const theme = "dark"; // its type: "dark"
Because a variable declared with const can never change, TypeScript infers its type as "dark": this exact string and nothing else. This is called a literal type. Besides strings, numbers and booleans have literal types too: 42, true.
A literal type on its own isn't very useful; saying "this variable can only be "dark"" rarely makes sense. The real power appears when you combine literals with a union:
type Theme = "light" | "dark" | "system";
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
function setTheme(theme: Theme) {
console.log(`Theme set to ${theme}`);
}
setTheme("dark");
// Error: Argument of type '"blue"' is not assignable to parameter of type 'Theme'.
setTheme("blue");
Now you can't pass any string you like to setTheme; only one of the three listed values gets through. Typos are off the table too: the editor suggests all three options as soon as you type setTheme(". Boolean is actually such a union as well; TypeScript represents boolean as true | false.
Here's the second part of the answer to the opening question: the current = "finished" line errors, because "finished" isn't one of Status's members. TypeScript's message reads Type '"finished"' is not assignable to type 'Status'. So that code had two errors: one from the literal union, and one from an invalid method call on a union.
Why Do Literals Widen Inside Objects?
There's a wall everyone hits once when working with literal types. Look at this code:
type Method = "GET" | "POST";
function request(url: string, method: Method) {
console.log(`${method} ${url}`);
}
const options = { url: "/api/users", method: "GET" };
// Error: Argument of type 'string' is not assignable to parameter of type 'Method'.
request(options.url, options.method);
"GET" is one of Method's members, so why the error? Because even though options is a const, its properties can still be changed. Tomorrow someone might write options.method = "DELETE". TypeScript accounts for that possibility and infers the property's type as string instead of "GET". This is called widening: the literal type widens to its broader base type. And string isn't assignable to Method.
The first fix is to state your intent explicitly with a type:
type Method = "GET" | "POST";
type RequestOptions = {
url: string;
method: Method;
};
function request(url: string, method: Method) {
console.log(`${method} ${url}`);
}
const options: RequestOptions = { url: "/api/users", method: "GET" };
request(options.url, options.method);
This is usually the best approach: what options should look like is defined in one place, and if you write a wrong method you see the error on the line of the declaration. But you may not want to write a separate type for every small object. That's where the second approach comes in.
A First Look at as const
type Method = "GET" | "POST";
function request(url: string, method: Method) {
console.log(`${method} ${url}`);
}
const options = {
url: "/api/users",
method: "GET",
} as const;
// options: { readonly url: "/api/users"; readonly method: "GET" }
request(options.url, options.method); // no error now
const methods = ["GET", "POST"] as const;
// methods: readonly ["GET", "POST"]
as const tells TypeScript "this value will never change, infer its type as narrowly as possible." It has two effects: every property becomes readonly, and no literal widens. That's why the type of options.method is now "GET". Applied to an array, the result is the readonly tuple we saw earlier: the order and the values of the elements are baked into the type.
You can also apply as const to a single property: { url: "/api/users", method: "GET" as const }. In that case only method stays narrow, and url becomes an ordinary string.
as const has another powerful side effect: deriving a literal union from an array automatically. We'll see the typeof and indexed access tools that requires in the intermediate series. We'll come back to as const itself in more detail in an upcoming post, together with satisfies and type assertions.
Why Unions Need Narrowing
Let's go back to formatId. The function really should accept both types and uppercase the value when it's a string. To do that, you have to show TypeScript which member the value is at a given point in the code:
function formatId(id: string | number) {
if (typeof id === "string") {
// here id: string
return id.toUpperCase();
}
// here id: number
return id.toFixed(0);
}
console.log(formatId("ab-7")); // AB-7
console.log(formatId(42)); // 42
The typeof check you know from the JavaScript series does two jobs here. At runtime it picks the right branch; at compile time TypeScript reads the check and understands that inside the if block id can only be a string. Since the block ends with return, by the time you get below it only one possibility is left: number.
This process is called narrowing. A union says "this value can be one of several things"; narrowing answers "which one exactly, right now" at every point in the code. Any code with a union needs narrowing at some point; otherwise you have to make do with the shared operations only.
Common Pitfalls
The first pitfall is where the parentheses go. (string | number)[] and string[] | number[] are not the same thing:
const mixed: (string | number)[] = [1, "two", 3];
// Error: Type '(string | number)[]' is not assignable to type 'string[] | number[]'.
const uniform: string[] | number[] = [1, "two", 3];
The first is a single array whose elements can each be a string or a number; mixed content is fine. The second says "either an array that's all strings or an array that's all numbers." Decide which one you want and place the parentheses accordingly.
The second pitfall is a literal getting lost inside a wider type. If you write type Size = string | "small", TypeScript keeps it as plain string; since "small" is already a string, it adds nothing to the union. That's also why the editor's autocomplete won't suggest "small". If you want "a few specific values plus any string," that's something a union can't express; either close the list or settle for a free-form string.
The third pitfall is accessing a property directly on a union of objects:
type Circle = { radius: number };
type Square = { size: number };
function area(shape: Circle | Square) {
// Error: Property 'radius' does not exist on type 'Circle | Square'.
return Math.PI * shape.radius ** 2;
}
Same rule again: radius exists only on Circle, not on Square. The cleanest way to narrow object unions is to give every member a shared "tag" property. That's called a discriminated union, and it's one of the main topics of the next post.
A Small Experiment
★☆☆ Paste the Theme and setTheme example into your editor. See which suggestions appear when you type setTheme(", then add "sepia" to Theme and watch the suggestions change.
★★☆ What's the type? Guess first: hover over sizes in const sizes = ["s", "m", "l"]; and in const sizes = ["s", "m", "l"] as const;. How many differences between the two types can you count?
★★☆ Does it compile? After writing let flag: true | false = true;, does the line flag = Math.random() > 0.5; error? Answer by thinking about how boolean is represented.
★★★ Take the request example in its broken form and fix it three different ways: by annotating the object, by adding as const to method only, and by adding as const to the whole object. In each one, try the line options.url = "/api/admins";. Which ones allow it?
In this post we saw that a union is a list of possibilities, and that TypeScript gives you full power only once you narrow those possibilities down. In the next post we'll look at all the narrowing tools: besides typeof, truthiness, equality, in and instanceof checks, discriminated unions, and a way to make sure you've handled every member of a union.