TypeScript Type Narrowing Guide | Unions, Guards & Discriminated Unions
이 글의 핵심
Learn to narrow union types in TypeScript: control-flow analysis, predicates, and patterns for DOM APIs, JSON payloads, and tagged unions.
Introduction
Advanced types in TypeScript let you define precise, flexible models for your data.
1. Union types
Concept
A union type means a value can be one of several types.
// 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:
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:
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:
// 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());
}
}
2. Intersection types
Concept
An intersection type must satisfy all of the combined types.
// 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
// 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()
};
3. Literal types
Concept
A literal type pins a value to an exact constant.
// 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
// 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
};
4. Type aliases
Concept
A type alias gives a name to a type.
// 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
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
5. Type narrowing
typeof guard
function processInput(input: string | number) {
if (typeof input === "string") {
return input.toUpperCase();
} else {
return input.toFixed(2);
}
}
instanceof guard
class Dog {
bark() {
console.log("Woof!");
}
}
class Cat {
meow() {
console.log("Meow!");
}
}
function makeSound(animal: Dog | Cat) {
if (animal instanceof Dog) {
animal.bark();
} else {
animal.meow();
}
}
makeSound(new Dog()); // Woof!
makeSound(new Cat()); // Meow!
in operator
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) {
animal.swim();
} else {
animal.fly();
}
}
User-defined type guards
interface User {
id: string;
name: string;
}
interface Admin {
id: string;
name: string;
permissions: string[];
}
function isAdmin(user: User | Admin): user is Admin {
return "permissions" in user;
}
function greet(user: User | Admin) {
if (isAdmin(user)) {
console.log(`Admin ${user.name}, permissions: ${user.permissions.join(", ")}`);
} else {
console.log(`User ${user.name}`);
}
}
6. Hands-on examples
Example 1: API response type
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
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" });
7. Common mistakes
Mistake 1: Misusing unions
// ❌ 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
// ❌ 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 }
Summary
Takeaways
- Union:
A | B(either) - Intersection:
A & B(all of) - Literal: exact value types
- Type alias: reusable names
- Narrowing: refine unions safely
Next steps
- TypeScript interfaces
- TypeScript generics
- TypeScript utility types
Related posts
- C++ union and std::variant
- TypeScript interfaces | Complete guide
- TypeScript generics | Complete guide
- C++ algorithm set operations
- C++ std::variant vs union