Advanced TypeScript Types | Union & Intersection
Key takeaways
Master Union (|), Intersection (&), literal types, type aliases, and narrowing with typeof, instanceof, in, and custom predicates—patterns for APIs and state machines.
Introduction
Advanced types in TypeScript let you define precise, flexible models for your data. Plain object-shape interfaces get you far, but real-world data rarely fits a single fixed shape — an API response is either a success payload or an error, a form field is either a string or null before validation, a UI state machine has several mutually exclusive statuses. Union and intersection types let you encode those alternatives and combinations directly in the type system, so the compiler — not a runtime check you forgot to write — catches the case where you handle only one branch.
Union types
Concept
A union type means a value can be one of several types. Structurally, string | number is not a new type the compiler invents — it’s a constraint meaning “this value’s actual type is one of these,” and the compiler tracks which one at each point in the code via control-flow analysis. That tracking is exactly what “narrowing” (covered below) relies on: as soon as you check typeof value === "string", the compiler knows the number branch of the union is impossible inside that block and lets you use string-only methods safely.
// Syntax: Type1 | Type2 | Type3
let value: string | number;
value = "text"; // ✅
value = 123; // ✅
// value = true; // ❌ error
Practical examples
// Function parameters
function printId(id: string | number) {
console.log(`ID: ${id}`);
}
printId(101); // ID: 101
printId("USER001"); // ID: USER001
// Arrays
let mixedArray: (string | number)[] = [1, "two", 3, "four"];
// Return type
function getResult(success: boolean): string | null {
return success ? "ok" : null;
}
Type guards
With unions you narrow types with type guards before using type-specific APIs. This is the fundamental trade union types make: you get precise typing for the set of possible shapes, but in exchange, the compiler forces you to check which member of the union you actually have before calling a method that isn’t shared by all of them — it’s the same discipline a switch on a tagged variant enforces in other languages, just driven by runtime checks the compiler can statically reason about.
function processValue(value: string | number) {
// typeof checks at runtime; the compiler narrows the type
if (typeof value === "string") {
// ✅ In this block, value is string
console.log(value.toUpperCase());
console.log(value.length);
console.log(value.trim());
} else {
// ✅ In else, value is number
console.log(value.toFixed(2));
console.log(value * 2);
console.log(value.toExponential());
}
}
processValue("hello"); // HELLO, 5
processValue(3.14159); // 3.14, 6.28318
Without type guards: the compiler rejects value.toUpperCase() and value.toFixed(2) outright, because it can only guarantee members that exist on every type in the union — the intersection of each type’s API surface, not the union of it. toString() and valueOf() are on both string and number, so those calls are always safe regardless of which branch value actually is at runtime.
function processValueBad(value: string | number) {
// ❌ Compile error: string | number has no shared toUpperCase
// console.log(value.toUpperCase());
// ❌ Compile error: string | number has no shared toFixed
// console.log(value.toFixed(2));
// ✅ Shared methods only
console.log(value.toString());
console.log(value.valueOf());
}
More narrowing patterns: typeof only works for the handful of JavaScript primitives it recognizes (string, number, boolean, bigint, symbol, undefined, function, object) — it can’t tell two different object shapes apart, which is why instanceof and in exist for narrowing between classes or structurally distinct object types. The user-defined predicate (value is string) is the escape hatch for narrowing logic the built-in guards can’t express — anything that returns a plain boolean only tells the compiler “this expression is true or false,” but a return type annotated x is T additionally tells the compiler “and when it’s true, narrow the parameter to T” for every caller, which the compiler could never infer from an arbitrary boolean-returning function’s body alone.
// 1. typeof (primitives)
function format(value: string | number | boolean) {
if (typeof value === "string") {
return value.toUpperCase();
} else if (typeof value === "number") {
return value.toFixed(2);
} else {
return value ? "true" : "false";
}
}
// 2. instanceof (class instances)
function handleError(error: Error | string) {
if (error instanceof Error) {
console.log(error.message);
console.log(error.stack);
} else {
console.log(error);
}
}
// 3. in operator (discriminate by property)
type Dog = { bark: () => void };
type Cat = { meow: () => void };
function makeSound(animal: Dog | Cat) {
if ("bark" in animal) {
animal.bark();
} else {
animal.meow();
}
}
// 4. User-defined type predicate
function isString(value: unknown): value is string {
return typeof value === "string";
}
function process(value: unknown) {
if (isString(value)) {
console.log(value.toUpperCase());
}
}
Intersection types
Concept
An intersection type must satisfy all of the combined types. Where a union widens what a value could be, an intersection narrows it by requiring every property from every combined type to be present at once — A & B is the type of a value that is simultaneously a valid A and a valid B. This is TypeScript’s structural stand-in for the kind of multiple-interface composition other languages express with multiple inheritance or trait/mixin composition, but without any of the runtime machinery — it’s purely a compile-time shape requirement.
// Syntax: Type1 & Type2 & Type3
type Person = {
name: string;
age: number;
};
type Employee = {
employeeId: string;
department: string;
};
type Staff = Person & Employee;
const staff: Staff = {
name: "Alice",
age: 30,
employeeId: "E001",
department: "Engineering"
};
Practical example
This pattern of layering a small “capability” type (Timestamped) onto a domain type (User) with & is common precisely because it avoids duplicating createdAt/updatedAt fields across every entity type that needs them — you define the capability once and intersect it in wherever it’s needed, similar in spirit to a mixin but resolved entirely at the type level with no actual runtime object composition required.
// Mixin-style composition
type Timestamped = {
createdAt: Date;
updatedAt: Date;
};
type User = {
id: string;
name: string;
email: string;
};
type UserWithTimestamp = User & Timestamped;
const user: UserWithTimestamp = {
id: "U001",
name: "Alice",
email: "[email protected]",
createdAt: new Date(),
updatedAt: new Date()
};
Literal types
Concept
A literal type pins a value to an exact constant. Instead of the general string type (any string at all), "left" as a type means only the specific string "left" is a valid value — TypeScript treats each literal as its own singleton type, and a union of literals like "left" | "right" | "up" | "down" becomes a closed, enumerable set the compiler can exhaustively check against, similar to an enum but backed by ordinary string/number values with no separate runtime representation.
// String literals
let direction: "left" | "right" | "up" | "down";
direction = "left"; // ✅
// direction = "top"; // ❌ error
// Numeric literals
let diceRoll: 1 | 2 | 3 | 4 | 5 | 6;
diceRoll = 3; // ✅
// diceRoll = 7; // ❌ error
// Boolean literal
let isTrue: true;
isTrue = true; // ✅
// isTrue = false; // ❌ error
Practical examples
Literal unions like HttpMethod and Status are the backbone of type-safe string enums in TypeScript codebases: calling request("/api/users", "PATCH") is a compile error, not a runtime surprise discovered when some server rejects an unsupported verb, because "PATCH" simply isn’t a member of the HttpMethod union. Combined with switch statements, a literal union like Status also enables exhaustiveness checking — if you add a new status value later and forget to handle it in a switch, assigning the unreachable default case to a variable typed never will fail to compile, flagging the gap immediately instead of leaving a silent unhandled case in production.
// HTTP methods
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
function request(url: string, method: HttpMethod) {
console.log(`${method} ${url}`);
}
request("/api/users", "GET"); // ✅
// request("/api/users", "PATCH"); // ❌ error
// State machines
type Status = "idle" | "loading" | "success" | "error";
interface ApiState {
status: Status;
data: any;
error: string | null;
}
const state: ApiState = {
status: "loading",
data: null,
error: null
};
Type aliases
Concept
A type alias gives a name to a type. Unlike an interface, a type alias is not restricted to object shapes — it can name a union, an intersection, a tuple, a function signature, or even a primitive as shown below with UserId/Age. Note that type UserId = string doesn’t create a distinct, incompatible type the way a newtype wrapper would in some other languages: it’s purely a label, so a plain string is still assignable wherever UserId is expected, and vice versa — TypeScript’s structural type system doesn’t enforce nominal distinctions unless you deliberately build a “branded type” pattern on top of it.
// Primitives
type UserId = string;
type Age = number;
let id: UserId = "U001";
let age: Age = 25;
// Object shape
type User = {
id: UserId;
name: string;
age: Age;
email: string;
};
const user: User = {
id: "U001",
name: "Alice",
age: 25,
email: "[email protected]"
};
Function types
Naming a function shape with a type alias (MathOperation) documents the contract once and lets every implementation be checked against it consistently — if subtract’s parameters or return type ever drift from (a: number, b: number) => number, the assignment itself fails to compile rather than surfacing as a mismatched-signature bug wherever the function is eventually called.
type MathOperation = (a: number, b: number) => number;
const add: MathOperation = (a, b) => a + b;
const subtract: MathOperation = (a, b) => a - b;
const multiply: MathOperation = (a, b) => a * b;
console.log(add(10, 5)); // 15
console.log(subtract(10, 5)); // 5
Type narrowing
Unions are only usable because of narrowing: after a runtime check the compiler recognizes, it treats the value as the narrower type inside that branch. Section 1 already used the basic guards (typeof for primitives, instanceof for class instances, in for plain object shapes, and user-defined predicates such as value is string), and the hands-on examples below rely on the most useful form, checking a discriminant property of a discriminated union.
Picking the right guard is mostly about what exists at runtime: typeof cannot tell two object shapes apart, instanceof needs an actual class (a type alias has nothing to check against at runtime), and in works for any object but narrows by property presence alone. A predicate is the escape hatch for everything else, with one caveat: the compiler trusts the predicate’s signature without checking that its body is correct.
How control flow analysis actually tracks these checks—equality and truthiness narrowing, assertion functions, exhaustiveness checking with assertNever, and the situations where narrowing is silently lost (closures over reassigned variables, mutable objects, structural lookalikes)—is covered in depth in Type Narrowing in TypeScript.
Hands-on examples
Example 1: API response type
ApiResponse<T> is a discriminated union — each member has a shared literal-typed field (success: true vs. success: false) that the compiler can use as a tag to figure out which branch you’re in. This is a deliberately more explicit alternative to throwing on failure: the caller is forced to check result.success before it can access result.data, because data only exists on the success: true branch and error only on the success: false one — trying to read result.data without narrowing first is a compile error, which pushes error handling to be checked statically rather than relying on a try/catch the caller might forget to write.
interface User {
id: string;
name: string;
}
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string };
async function fetchUser(id: string): Promise<ApiResponse<User>> {
try {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return { success: true, data };
} catch (error) {
return { success: false, error: "User not found" };
}
}
// Usage
const result = await fetchUser("U001");
if (result.success) {
console.log("User:", result.data.name);
} else {
console.error("Error:", result.error);
}
Example 2: State machine
This is the same discriminated-union pattern applied to modeling UI state — status is the tag, and each member only carries the fields that are actually meaningful for that state (data only exists when status is "success", error only when it’s "error"). This sidesteps a common bug class where a single flat object has optional data/error/isLoading fields that can, in principle, be set in nonsensical combinations (e.g., isLoading: true and data simultaneously populated) — with a discriminated union, invalid combinations of fields are simply not representable, because each state is its own distinct object shape.
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: any }
| { status: "error"; error: string };
function handleState(state: State) {
switch (state.status) {
case "idle":
console.log("Idle");
break;
case "loading":
console.log("Loading...");
break;
case "success":
console.log("Data:", state.data);
break;
case "error":
console.error("Error:", state.error);
break;
}
}
// Usage
handleState({ status: "idle" });
handleState({ status: "loading" });
handleState({ status: "success", data: { name: "Alice" } });
handleState({ status: "error", error: "Network error" });
Common mistakes
Mistake 1: Misusing unions
The broken version fails because .length only exists on string, not number — the compiler enforces exactly the “shared API surface only” rule described above, and there’s no implicit fallback. The fix has to explicitly branch and produce a comparable value for both cases (converting the number to a string first), rather than hoping a property happens to exist on both types.
// ❌ Wrong
function getLength(value: string | number) {
return value.length; // error: number has no length
}
// ✅ Correct
function getLength(value: string | number) {
if (typeof value === "string") {
return value.length;
}
return value.toString().length;
}
Mistake 2: Conflicting intersections
When two intersected types declare the same property with incompatible primitive types, TypeScript doesn’t pick one or error immediately — it computes the intersection of string and number for that property, which is never (no value can simultaneously be both), silently making the whole property impossible to satisfy. The type still “compiles,” but any attempt to actually construct a value of type C fails, since there’s no legal value for value. This is a good reason to be deliberate about which types you intersect: intersecting two independently-designed object types is safe when their field sets don’t overlap, but risky when they might define the same field name with different meanings.
// ❌ Conflicting property types
type A = { value: string };
type B = { value: number };
type C = A & B; // value becomes never
// ✅ Compatible intersection
type A = { name: string };
type B = { age: number };
type C = A & B; // { name: string; age: number }
Next in the series
TypeScript interfaces comes next, including when an interface and a type alias behave differently. The union and narrowing patterns from this post come back in generics.
Frequently Asked Questions (FAQ)
Q. Why does an intersection of two object types sometimes become never?
A. An intersection requires a value to satisfy every member at once, so if both types declare the same property with incompatible types, such as { id: string } & { id: number }, that property becomes never. When the conflicting properties are discriminants (literal types), TypeScript reduces the whole intersection to never, and the error only appears later where you try to create a value. If you meant “one or the other”, use a union instead; if you meant to override a field, use Omit<A, 'id'> & B.
Q. What is a Union type?
A. A type that can be one of several types (A | B) — the compiler only allows operations valid on every member of the union unless you narrow first.
Q. What is an Intersection type?
A. A type that must satisfy all of the combined types (A & B) — a value of that type needs every property from every member type present at once.
Q. Type alias vs interface?
A. Type aliases can name any type shape, including unions, tuples, and function signatures; interfaces are specialized for object shapes, support declaration merging (re-opening the same interface to add more members), and tend to produce clearer error messages when extended.
Related posts
- Type Narrowing in TypeScript
- Get started with TypeScript | Install & tsconfig
- TypeScript interfaces | Complete guide
- TypeScript generics | Complete guide
- TypeScript utility types