TypeScript union types let a variable or a parameter hold a value of one of several types. We write the types separated by a vertical bar (|), so string | number accepts both “37” and 37 and rejects everything else.
We use unions for values that come in more than one form, such as an ID that can be a number or a string, or an API result that is either data or an error.
The following example shows a union with a typeof check and a discriminated union with an exhaustive switch, which cover most of what we write with unions in practice. Each snippet compiles under TypeScript 7.0.2 and runs on Node.js 22.
// 1. Declare a union
let id: string | number = 101;
id = "101"; // also OK
// 2. Narrow with typeof
function format(value: string | number): string {
return typeof value === "string" ? value.toUpperCase() : value.toFixed(2);
}
const a = format("apple"); // a = "APPLE"
const b = format(5); // b = "5.00"
// 3. Discriminated union
type Shape =
| { kind: "square"; size: number }
| { kind: "rect"; width: number; height: number };
// 4. Narrow on the kind property, exhaustive with never
function area(shape: Shape): number {
switch (shape.kind) {
case "square":
return shape.size * shape.size;
case "rect":
return shape.width * shape.height;
default:
const unreachable: never = shape;
return unreachable;
}
}
const s = area({ kind: "square", size: 3 }); // s = 9
const r = area({ kind: "rect", width: 2, height: 4 }); // r = 8
Notice that format() calls toUpperCase() only after the typeof check has narrowed value to string.
Declaring a union takes one line, and in daily work narrowing matters more, because before we call toUpperCase() or read a property, the compiler wants proof of which member of the union we have. Next, we look at the syntax and at the checks that narrow a union, followed by discriminated unions and exhaustive switch statements that use never to catch a forgotten case.
1. Declaring TypeScript Union Types
We can use a union anywhere a type can appear, for example on a variable, a parameter, a return type or inside a type alias. A library catalog is a typical case, where old books keep a numeric ID and new books get a string ID, so the lookup function takes number | string. For a long union, we write a type alias with one member per line, and TypeScript allows a leading | before the first member for that layout.
// 1. Variable
let age: number | string = 37;
age = "37";
// 2. Function parameter
function show(value: number | string): string {
return "Value: " + value;
}
const text = show(40); // text = "Value: 40"
// 3. Type alias, optionally with a leading pipe
type Id =
| number
| string;
const id: Id = 7;
// 4. Array of a union vs union of arrays
const mixed: (number | string)[] = [1, "two", 3];
const either: number[] | string[] = ["one", "two"];
In the last example, the parentheses change the meaning.
- The type (number | string)[] is one array that can mix numbers and strings.
- The type number[] | string[] is either an array of only numbers or an array of only strings, so [“one”, 2] is rejected for either.
When we assign a value outside the union, the compiler reports an error that lists the allowed types.
let id: string | number = 101;
id = true;
src/err.ts(2,1): error TS2322: Type 'boolean' is not assignable to type 'string | number'.
2. What We Can Do With a Union Value
The compiler allows only the operations that are valid for every member of the union. Both string and number have a toString() method, so we can call it on string | number without any check.
function label(value: string | number): string {
return value.toString(); // both types have toString()
}
const l1 = label(5); // l1 = "5"
const l2 = label("apple"); // l2 = "apple"
When only one member has the method, the compiler rejects the call, because the value might be the other type at runtime.
function label(value: string | number): string {
return value.toUpperCase();
}
src/err.ts(2,16): error TS2339: Property 'toUpperCase' does not exist on type 'string | number'.
Property 'toUpperCase' does not exist on type 'number'.
The fix is a runtime check that narrows the type, not a type assertion such as (value as string), which only hides the problem.
3. Narrowing a Union Type
Narrowing means that the compiler reduces a union to fewer members inside a block, based on a check that runs at runtime. After if (typeof value === “number”), the compiler knows that value is a number inside the block and a string after it. Which check we use depends on the kind of members in the union.
| Union members | Check that narrows | Example |
|---|---|---|
| Primitives (string, number, boolean, bigint) | typeof | typeof value === “string” |
| An array and a non-array | Array.isArray() | Array.isArray(input) |
| Class instances (Date, Error, own classes) | instanceof | value instanceof Date |
| Object types with different properties | in | “age” in owner |
| Object types with a common tag property | === or switch on the tag | result.status === “ok” |
| Anything else | A function that returns value is Type | isString(value) |
3.1. typeof for Primitive Members
The typeof operator returns the runtime type of a value as a string such as “string”, “number” or “boolean”. When the check ends with an early return, the compiler narrows the rest of the function to the remaining members.
function pad(value: string | number): string {
if (typeof value === "number") {
return value.toFixed(1); // value: number
}
return value.trim(); // value: string
}
const p1 = pad(5); // p1 = "5.0"
const p2 = pad(" apple "); // p2 = "apple"
Because typeof returns “object” for arrays, plain objects and null, it cannot tell those apart, so we need the next checks for them.
3.2. Array.isArray() and instanceof
A parameter that accepts one value or a list of values is a common union, and Array.isArray() narrows it to the array member. For class instances, instanceof checks the prototype chain and narrows the value to that class.
// 1. Array.isArray()
function total(input: number | number[]): number {
if (Array.isArray(input)) {
return input.reduce((sum, n) => sum + n, 0); // input: number[]
}
return input; // input: number
}
const t1 = total(5); // t1 = 5
const t2 = total([1, 2, 3]); // t2 = 6
// 2. instanceof
function year(value: Date | string): number {
if (value instanceof Date) {
return value.getFullYear(); // value: Date
}
return new Date(value).getFullYear(); // value: string
}
const y = year("2026-10-03"); // y = 2026
The instanceof operator works only with classes, because interfaces and type aliases do not exist at runtime, so an instanceof check against an interface does not compile.
3.3. The in Operator for Object Types
When the union members are object types, we check for a property that only one of them has. For example, a billing app sends each invoice to an owner that is either a person or a company. The expression “age” in owner is true when the object has an age property, and the compiler narrows owner to the type that declares it.
type Person = { name: string; age: number };
type Company = { name: string; employees: number };
function describe(owner: Person | Company): string {
if ("age" in owner) {
return owner.name + " is " + owner.age; // owner: Person
}
return owner.name + " has " + owner.employees + " employees";
}
const d1 = describe({ name: "Lokesh", age: 37 }); // d1 = "Lokesh is 37"
const d2 = describe({ name: "Acme", employees: 40 }); // d2 = "Acme has 40 employees"
We can read the shared name property before the check, as section 2 explained, so only age and employees need narrowing.
3.4. Custom Type Guards
A type guard is a function whose return type is a type predicate, written value is Type. When the function returns true, the compiler narrows the argument to that type. We write one when the check is longer than one expression or is needed in several places.
Since TypeScript 5.5, the compiler also infers type predicates from simple arrow functions, so filter() removes null and undefined from the element type without any extra code.
// 1. Custom type guard
function isString(value: unknown): value is string {
return typeof value === "string";
}
const input: unknown = "banana";
if (isString(input)) {
const len = input.length; // len = 6
}
// 2. Inferred type predicate in filter() (TypeScript 5.5+)
const ages = [37, null, 40, undefined];
const known = ages.filter((a) => a != null); // known: number[] = [37, 40]
The compiler does not verify the body of a custom type guard. If isString() returned true for numbers, the compiler would still treat the value as a string, so the body of a guard must check the same thing that its predicate claims.
4. Discriminated Unions
A discriminated union (also called a tagged union) is a union of object types that all share one property, the tag, with a different literal value in each member. When we check the tag with === or a switch, the compiler narrows the whole object, so it knows which other properties exist.
We use discriminated unions to model results and events in TypeScript, and for Java developers they are similar to a sealed interface with one record per case. For example, a payment API returns either a confirmed payment with an amount or a declined payment with a reason, and the status tag tells the two apart.
type Result =
| { status: "ok"; value: number }
| { status: "error"; message: string };
function show(result: Result): string {
if (result.status === "ok") {
return "Value " + result.value; // result has value
}
return "Failed: " + result.message; // result has message
}
const ok = show({ status: "ok", value: 37 }); // ok = "Value 37"
const err = show({ status: "error", message: "timeout" }); // err = "Failed: timeout"
Compared with the in operator, a tag is explicit and stays correct when the types change, because adding a property to one member never changes which branch runs. The tag values are literal types, and we can derive a union of tag values from a constant array with as const.
5. Exhaustive Checks With never
When a switch handles every member of a union, the type left in the default branch is never (that is, the type with no values). So when we assign the value to a variable of type never, the code compiles only while every member has a case. As soon as someone adds a member to the union and forgets a case, the compiler reports an error at the place that needs the change.
type Shape =
| { kind: "square"; size: number }
| { kind: "rect"; width: number; height: number }
| { kind: "circle"; radius: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "square":
return shape.size * shape.size;
case "rect":
return shape.width * shape.height;
case "circle":
return Math.round(Math.PI * shape.radius * shape.radius);
default:
const unreachable: never = shape; // error if a case is missing
return unreachable;
}
}
const c = area({ kind: "circle", radius: 2 }); // c = 13
Removing the “circle” case from area() gives an error that names the forgotten member.
src/err.ts(13,13): error TS2322: Type '{ kind: "circle"; radius: number; }' is not assignable to type 'never'.
Without the default branch, the compiler still reports something, because the function can end without returning a number, and the message is error TS2366: Function lacks ending return statement and return type does not include ‘undefined’. The TS2366 message does not say which case is missing, and it does not appear at all for functions that return void, whereas the never assignment works in both situations.
6. Union vs Intersection Types
The & operator builds an intersection type, which combines types the opposite way. A union value needs to match at least one member; an intersection value needs to match all members at the same time.
type Named = { name: string };
type Aged = { age: number };
// 1. Union: Named or Aged (or both)
const either: Named | Aged = { age: 40 };
// 2. Intersection: Named and Aged
const both: Named & Aged = { name: "John", age: 40 };
Each combination has its own use.
- We use a union when a value comes in different forms and the code must branch on the form.
- We use an intersection to combine several object types into one, for example a base type plus extra fields.
Unions and intersections are two of the TypeScript types that we build from other types.
7. Running the Union Type Examples
The union-types folder on GitHub contains each snippet as a separate file and an index.ts that runs them in order. To run it, we need Node.js 22 or newer, and the project uses TypeScript 7.0.2.
npm install
npm start
The program prints each section with the values from the snippet comments. We left out the snippets that do not compile, such as the switch without the “circle” case.
8. Conclusion
A union type lists the types a value can have, and the compiler allows only the operations that all of them support. Before we use a member-specific method or property, we narrow the union with typeof, Array.isArray(), instanceof, in or a type guard. For object types, a discriminated union with a literal tag property and an exhaustive switch that ends in a never assignment gives the strongest checks, because adding a new case makes every incomplete switch fail to compile.
9. References
The TypeScript Handbook chapter on narrowing describes every check we used here, and the 5.5 release notes describe inferred type predicates.
- TypeScript Handbook: Union types
- TypeScript Handbook: Narrowing
- TypeScript 5.5 release notes: inferred type predicates
- MDN: typeof
- MDN: in operator
Happy Learning !!