TypeScript Type Narrowing Guide | Unions, Guards & Discriminated Unions

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

  1. Union: A | B (either)
  2. Intersection: A & B (all of)
  3. Literal: exact value types
  4. Type alias: reusable names
  5. Narrowing: refine unions safely

Next steps