Advanced TypeScript Types | Union, Intersection, and Literal Types

Advanced TypeScript Types | Union, Intersection, and Literal Types

이 글의 핵심

A practical guide to advanced TypeScript types: unions, intersections, literals, aliases, narrowing, and real-world API and state-machine examples.

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