TypeScript 고급 타입 | Union, Intersection, Literal 타입

이 글의 핵심

Union 타입으로 값이 여러 형태일 수 있다는 사실은 표현할 수 있지만, 그 상태로는 공통 멤버에만 접근할 수 있어 타입 좁히기가 반드시 따라와야 합니다. 분기마다 타입을 좁히는 가드 작성법, 두 타입을 합쳤을 때 충돌한 프로퍼티가 never가 되는 이유를 짚고, status 필드로 구분하는 상태 머신 예제로 마무리합니다.

들어가며

TypeScript의 고급 타입을 쓰면, 값에 붙는 명찰을 더 세밀하게 나눌 수 있습니다. “문자열 또는 숫자만 허용”처럼 조합·제한을 표현하면, 런타임(프로그램이 실제로 실행되는 때) 전에 의도를 문서처럼 고정할 수 있습니다.

Union 타입과 타입 가드

개념

Union 타입은 여러 타입 중 하나일 수 있는 타입입니다.

// 문법: 타입1 | 타입2 | 타입3
let value: string | number;
value = "문자열";  // ✅
value = 123;       // ✅
// value = true;   // ❌ 에러

실전 예제

// 함수 매개변수
function printId(id: string | number) {
    console.log(`ID: ${id}`);
}
printId(101);        // ID: 101
printId("USER001");  // ID: USER001
// 배열
let mixedArray: (string | number)[] = [1, "two", 3, "four"];
// 함수 반환 타입
function getResult(success: boolean): string | null {
    return success ? "성공" : null;
}

타입 가드 (Type Guard)

Union 타입을 사용할 때는 타입 가드로 타입을 좁혀서(narrowing) 안전하게 사용해야 합니다:

function processValue(value: string | number) {
    // typeof 연산자로 런타임에 타입 체크
    // TypeScript 컴파일러는 이를 인식하고 타입을 좁혀줌 (Type Narrowing)
    
    if (typeof value === "string") {
        // ✅ 이 블록 안에서 value는 string 타입으로 좁혀짐
        // string 전용 메서드를 안전하게 사용 가능
        console.log(value.toUpperCase());  // 대문자로 변환
        console.log(value.length);         // 문자열 길이
        console.log(value.trim());         // 공백 제거
    } else {
        // ✅ else 블록에서는 value가 number 타입으로 좁혀짐
        // (string이 아니면 number이므로)
        // number 전용 메서드를 안전하게 사용 가능
        console.log(value.toFixed(2));     // 소수점 2자리로 포맷
        console.log(value * 2);            // 숫자 연산
        console.log(value.toExponential()); // 지수 표기법
    }
}
processValue("hello");  // HELLO, 5
processValue(3.14159);  // 3.14, 6.28318

타입 가드가 없다면:

function processValueBad(value: string | number) {
    // ❌ 컴파일 에러: string | number에는 toUpperCase()가 없음
    // console.log(value.toUpperCase());
    // TypeScript는 value가 string인지 number인지 모르므로
    // string 전용 메서드 호출을 허용하지 않음
    
    // ❌ 컴파일 에러: string | number에는 toFixed()가 없음
    // console.log(value.toFixed(2));
    
    // ✅ 타입 가드 없이는 공통 메서드만 사용 가능
    console.log(value.toString());  // 둘 다 toString() 있음
    console.log(value.valueOf());   // 둘 다 valueOf() 있음
}

Union 타입에서 “공통 멤버만 쓸 수 있다”는 규칙은 처음에 직관과 반대로 느껴집니다. string | number라고 쓰면 두 타입의 능력을 합친 것 같지만, 실제로는 “string일 수도 있고 number일 수도 있는 값”이므로 어느 쪽이든 확실히 가진 멤버만 안전합니다. 그래서 값의 집합으로 보면 union은 넓어지고, 쓸 수 있는 멤버는 좁아집니다. 에러 메시지도 이 관점으로 읽으면 이해가 쉽습니다. Property 'toUpperCase' does not exist on type 'string | number'. Property 'toUpperCase' does not exist on type 'number'.는 “number인 경우를 처리하지 않았다”는 뜻입니다. 다양한 타입 가드 방법:

// 1. typeof 타입 가드 (원시 타입)
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 ? "참" : "거짓";
    }
}
// 2. instanceof 타입 가드 (클래스 인스턴스)
function handleError(error: Error | string) {
    if (error instanceof Error) {
        // Error 객체의 프로퍼티 접근 가능
        console.log(error.message);
        console.log(error.stack);
    } else {
        // string
        console.log(error);
    }
}
// 3. in 연산자 타입 가드 (프로퍼티 존재 확인)
type Dog = { bark: () => void };
type Cat = { meow: () => void };
function makeSound(animal: Dog | Cat) {
    if ("bark" in animal) {
        // animal은 Dog 타입으로 좁혀짐
        animal.bark();
    } else {
        // animal은 Cat 타입으로 좁혀짐
        animal.meow();
    }
}
// 4. 사용자 정의 타입 가드 (Type Predicate)
function isString(value: unknown): value is string {
    return typeof value === "string";
}
function process(value: unknown) {
    if (isString(value)) {
        // value는 string으로 좁혀짐
        console.log(value.toUpperCase());
    }
}

네 가지 가드는 동작하는 대상이 다릅니다. typeof는 "string", "number", "boolean", "object", "function", "undefined", "bigint", "symbol" 같은 원시 타입 분류만 알려 주고, 모든 객체와 배열은 물론 null까지 "object"로 나옵니다. typeof x === "object"로 좁힌 뒤에도 x가 null일 수 있어서 TypeScript가 'x' is possibly 'null'이라고 경고하는 이유가 이것입니다. instanceof는 프로토타입 체인을 확인하므로 class로 만든 인스턴스에만 의미가 있고, type이나 interface로 정의한 객체 타입에는 쓸 수 없습니다(타입은 컴파일 후 사라지므로 런타임에 비교할 대상이 없습니다). 또 iframe이나 다른 realm에서 만든 Error는 instanceof Error가 false가 될 수 있습니다.

in 연산자는 프로퍼티 이름의 존재로 구분합니다. 편리하지만, 두 타입 모두에 선택적 프로퍼티(bark?: () => void)로 있으면 좁히기가 제대로 되지 않고, 런타임 객체에 우연히 같은 이름의 프로퍼티가 붙어 있으면 잘못 분기합니다. 서로 다른 모양의 객체를 구분하는 가장 견고한 방법은 뒤의 상태 머신 예제처럼 status나 kind 같은 리터럴 타입 판별 필드를 두는 것입니다.

사용자 정의 타입 가드(value is string)에는 중요한 함정이 있습니다. TypeScript는 함수 본문이 정말로 그 타입을 확인하는지 검사하지 않고 믿습니다. function isString(v: unknown): v is string { return true; }도 컴파일되고, 이 함수를 통과한 숫자는 string으로 취급되어 toUpperCase is not a function 런타임 에러로 이어집니다. 타입 가드는 as 단언과 같은 수준의 책임을 개발자에게 넘기는 것이므로, 로직을 단순하게 유지하고 테스트를 붙여 두는 것이 좋습니다. TypeScript 5.5부터는 x => typeof x === "string" 같은 단순한 함수의 타입 가드를 컴파일러가 스스로 추론하기도 합니다.


Intersection 타입으로 타입 합치기

개념

Intersection 타입은 여러 타입을 모두 만족하는 타입입니다.

// 문법: 타입1 & 타입2 & 타입3
type Person = {
    name: string;
    age: number;
};
type Employee = {
    employeeId: string;
    department: string;
};
type Staff = Person & Employee;
const staff: Staff = {
    name: "홍길동",
    age: 30,
    employeeId: "E001",
    department: "개발팀"
};

실전 예제

// 믹스인 패턴
type Timestamped = {
    createdAt: Date;
    updatedAt: Date;
};
type User = {
    id: string;
    name: string;
    email: string;
};
type UserWithTimestamp = User & Timestamped;
const user: UserWithTimestamp = {
    id: "U001",
    name: "홍길동",
    email: "[email protected]",
    createdAt: new Date(),
    updatedAt: new Date()
};

Intersection은 “필드를 합친다”고 생각하기 쉽지만, 정확히는 두 타입의 조건을 동시에 만족하는 값의 타입입니다. 서로 다른 필드를 가진 객체 타입끼리는 결과가 필드 합집합처럼 보이지만, 같은 이름의 필드가 있으면 그 필드의 타입끼리도 &로 합쳐집니다. 이 점이 interface의 extends와 가장 크게 다른 부분입니다. interface B extends A에서 같은 필드를 호환되지 않는 타입으로 다시 선언하면 Interface 'B' incorrectly extends interface 'A' 에러가 선언 시점에 바로 나지만, A & B는 조용히 never 필드를 만들고 값을 만들 때가 되어서야 에러가 납니다(아래 “실수 2”). 공통 필드를 확장하는 상속 관계라면 interface extends가, 서로 독립적인 관심사(타임스탬프, 소유자 정보 등)를 섞는 믹스인이라면 &가 어울립니다.


Literal 타입으로 값 범위 좁히기

개념

Literal 타입은 정확한 값을 타입으로 지정합니다.

// 문자열 리터럴
let direction: "left" | "right" | "up" | "down";
direction = "left";   // ✅
// direction = "top"; // ❌ 에러
// 숫자 리터럴
let diceRoll: 1 | 2 | 3 | 4 | 5 | 6;
diceRoll = 3;   // ✅
// diceRoll = 7; // ❌ 에러
// 불리언 리터럴
let isTrue: true;
isTrue = true;   // ✅
// isTrue = false; // ❌ 에러

실전 예제

// HTTP 메서드
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
function request(url: string, method: HttpMethod) {
    console.log(`${method} ${url}`);
}
request("/api/users", "GET");   // ✅
// request("/api/users", "PATCH"); // ❌ 에러
// 상태 관리
type Status = "idle" | "loading" | "success" | "error";
interface ApiState {
    status: Status;
    data: any;
    error: string | null;
}
const state: ApiState = {
    status: "loading",
    data: null,
    error: null
};

리터럴 타입을 쓰다 보면 반드시 한 번은 만나는 에러가 있습니다.

let method = "GET";
request("/api/users", method);
// ❌ Argument of type 'string' is not assignable to parameter of type 'HttpMethod'.

let으로 선언한 변수는 나중에 다른 문자열을 넣을 수 있으므로 TypeScript가 타입을 "GET"이 아니라 string으로 넓힙니다(widening). const method = "GET"으로 선언하면 값이 바뀔 수 없으니 "GET" 리터럴 타입이 유지됩니다. 객체 리터럴도 같은 문제가 있습니다. const config = { method: "GET" }에서 config.method는 string입니다. 객체의 프로퍼티는 나중에 바뀔 수 있기 때문입니다. 이때는 { method: "GET" } as const로 모든 프로퍼티를 읽기 전용 리터럴로 고정하거나, const config: { method: HttpMethod } = ...처럼 타입을 명시하거나, TypeScript 4.9의 satisfies HttpMethod를 쓰면 됩니다.

위 ApiState는 설계상 약점이 있습니다. status가 "success"인데 data가 null이거나, "idle"인데 error에 문자열이 들어 있는 불가능한 상태도 타입상 허용됩니다. 필드가 서로 독립적이라 조합이 4 × 2 × 2 = 16가지가 되기 때문입니다. data: any도 이 필드를 쓰는 곳의 타입 검사를 전부 끄는 셈입니다. 이 문제를 해결하는 방법이 6절의 “상태 머신” 예제입니다.


Type Alias로 타입에 이름 붙이기

개념

Type Alias는 타입에 이름을 붙입니다.

// 기본 사용
type UserId = string;
type Age = number;
let id: UserId = "U001";
let age: Age = 25;
// 객체 타입
type User = {
    id: UserId;
    name: string;
    age: Age;
    email: string;
};
const user: User = {
    id: "U001",
    name: "홍길동",
    age: 25,
    email: "[email protected]"
};

함수 타입

// 함수 타입 정의
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 Alias는 새 타입을 만드는 것이 아니라 기존 타입에 별명을 붙이는 것입니다. 그래서 type UserId = string과 type OrderId = string은 컴파일러에게 완전히 같은 타입이며, UserId 자리에 OrderId를 넣어도 에러가 나지 않습니다. 이름으로 의도를 드러내는 문서화 효과는 있지만 실수를 막아 주지는 않습니다. 두 ID를 정말로 구분하고 싶다면 type UserId = string & { readonly __brand: "UserId" } 같은 “브랜드 타입” 기법을 씁니다.

type과 interface 중 무엇을 쓸지는 자주 나오는 질문입니다. 객체 모양만 정의한다면 둘은 거의 같고, 차이는 세 가지입니다. type만 union·리터럴·튜플·함수 타입 같은 비객체 타입에 이름을 붙일 수 있고, interface만 같은 이름으로 여러 번 선언해 자동으로 합쳐지는 선언 병합(declaration merging)이 됩니다(라이브러리 타입 확장에 쓰임). 그리고 앞서 본 것처럼 확장 시 충돌을 잡는 시점이 다릅니다. 팀마다 규칙이 다르지만 “객체 모양과 공개 API는 interface, 나머지는 type”으로 나누는 경우가 많습니다.


typeof·instanceof·in·사용자 정의 가드로 좁히기

typeof 가드

function processInput(input: string | number) {
    if (typeof input === "string") {
        // input은 string
        return input.toUpperCase();
    } else {
        // input은 number
        return input.toFixed(2);
    }
}

instanceof 가드

class Dog {
    bark() {
        console.log("멍멍!");
    }
}
class Cat {
    meow() {
        console.log("야옹!");
    }
}
function makeSound(animal: Dog | Cat) {
    if (animal instanceof Dog) {
        animal.bark();
    } else {
        animal.meow();
    }
}
makeSound(new Dog());  // 멍멍!
makeSound(new Cat());  // 야옹!

in 연산자

type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
    if ("swim" in animal) {
        animal.swim();
    } else {
        animal.fly();
    }
}

사용자 정의 타입 가드

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(`관리자 ${user.name}, 권한: ${user.permissions.join(", ")}`);
    } else {
        console.log(`사용자 ${user.name}`);
    }
}

예제: API 응답 타입과 상태 머신

예제 1: API 응답 타입

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: "사용자를 찾을 수 없습니다" };
    }
}
// 사용
const result = await fetchUser("U001");
if (result.success) {
    console.log("사용자:", result.data.name);
} else {
    console.error("에러:", result.error);
}

이 ApiResponse가 앞의 ApiState보다 나은 점은 success가 true일 때만 data가, false일 때만 error가 존재한다는 사실을 타입으로 표현했다는 것입니다. if (result.success) 분기 안에서 result.error에 접근하면 컴파일 에러가 납니다. 이렇게 공통 필드의 리터럴 값으로 구분하는 union을 판별 유니온(discriminated union)이라고 합니다.

다만 fetchUser의 구현에는 두 가지 구멍이 있습니다. 첫째, fetch는 404나 500 응답에서도 예외를 던지지 않습니다. 네트워크 자체가 실패할 때만 reject되므로, 존재하지 않는 사용자를 조회하면 서버의 에러 JSON이 { success: true, data }로 포장되어 돌아옵니다. if (!response.ok)로 상태 코드를 먼저 확인해야 합니다. 둘째, response.json()의 반환 타입은 Promise<any>라서, 서버가 name 대신 userName을 보내도 컴파일러는 아무 말도 하지 않습니다. 타입은 컴파일 시점에만 존재하므로 외부에서 들어오는 데이터는 타입이 보장해 주지 못합니다. 제가 TypeScript 프로젝트에서 가장 자주 본 런타임 에러가 바로 이렇게 “타입상으로는 User인데 실제로는 모양이 다른 객체”에서 나왔고, 그래서 API 경계에서는 zod 같은 스키마 라이브러리로 런타임 검증을 한 번 거친 뒤 타입을 붙이는 방식을 선호하게 되었습니다.

예제 2: 상태 머신

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("대기 중");
            break;
        case "loading":
            console.log("로딩 중...");
            break;
        case "success":
            console.log("데이터:", state.data);
            break;
        case "error":
            console.error("에러:", state.error);
            break;
    }
}
// 사용
handleState({ status: "idle" });
handleState({ status: "loading" });
handleState({ status: "success", data: { name: "홍길동" } });
handleState({ status: "error", error: "네트워크 에러" });

switch (state.status)의 각 case 안에서 state는 해당 멤버로 좁혀지므로, "success" 분기에서만 state.data를, "error" 분기에서만 state.error를 쓸 수 있습니다. 앞의 ApiState에서 가능했던 “성공인데 데이터가 없는” 상태는 이제 만들 수조차 없습니다.

이 패턴의 진짜 가치는 나중에 상태가 추가될 때 드러납니다. 누군가 { status: "cancelled" }를 추가했는데 handleState에 case를 빠뜨리면, 위 코드는 컴파일도 되고 아무것도 하지 않은 채 넘어갑니다. default 분기에 never 검사를 넣으면 이를 컴파일 에러로 만들 수 있습니다.

default: {
    const _exhaustive: never = state;
    throw new Error(`처리하지 않은 상태: ${JSON.stringify(_exhaustive)}`);
}

모든 case를 처리했다면 default에 도달한 state의 타입은 never이므로 대입이 성공합니다. 새 상태가 추가되면 state가 { status: "cancelled" } 타입이 되어 Type '{ status: "cancelled"; }' is not assignable to type 'never' 에러가 나고, 처리해야 할 곳을 컴파일러가 모두 찾아 줍니다. 이 예제의 data: any도 data: User처럼 구체적인 타입으로 바꾸거나, 여러 데이터 타입에 쓰려면 State<T>로 제네릭화하는 편이 좋습니다.


Union 오해와 Intersection 충돌

실수 1: Union 타입 오해

// ❌ 잘못된 사용
function getLength(value: string | number) {
    return value.length;  // 에러: number에는 length 없음
}
// ✅ 올바른 사용
function getLength(value: string | number) {
    if (typeof value === "string") {
        return value.length;
    }
    return value.toString().length;
}

실수 2: Intersection 타입 충돌

// ❌ 충돌하는 타입
type A = { value: string };
type B = { value: number };
type C = A & B;  // value는 never 타입
// ✅ 올바른 사용
type A = { name: string };
type B = { age: number };
type C = A & B;  // { name: string; age: number }

고급 타입 요약

  1. Union: A | B (또는)
  2. Intersection: A & B (그리고)
  3. Literal: 정확한 값
  4. Type Alias: 타입 이름 지정
  5. Type Narrowing: 타입 좁히기

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 두 타입을 &로 합쳤는데 특정 프로퍼티가 never가 되는 이유는 무엇인가요?

A. Intersection은 두 타입의 조건을 모두 만족해야 하므로, 같은 이름의 프로퍼티가 한쪽은 string이고 다른 쪽은 number라면 둘 다인 값은 존재할 수 없어 never가 됩니다. 이런 경우 에러가 정의 시점이 아니라 값을 만들 때 드러나서 원인을 찾기 어렵습니다. 서로 다른 필드를 합치는 용도로만 Intersection을 쓰고, 같은 필드가 여러 타입 중 하나라는 의미라면 Union으로 표현해야 합니다.