1. 개요
1편에서 TypeScript 의 타입이 이름이 아니라 모양으로 판단된다는 걸 봤다. 이번 편은 그 모양을 조합하는 두 연산자, 유니온(|)과 인터섹션(&) 을 다룬다. 그리고 유니온을 쓰면 반드시 따라오는 작업인 타입 좁히기(narrowing) 까지. 유니온은 선언하는 순간보다 그걸 실제로 나눠서 쓸 때가 더 중요하다.
2. 핵심 내용
2-1. 유니온: 이거 아니면 저거
유니온은 “여러 타입 중 하나” 를 뜻한다.
type Status = "pending" | "approved" | "rejected"
type Id = string | number
function findUser(id: Id) {
// id 는 string 이거나 number 다
}유니온 타입의 값에 접근할 때는 모든 멤버 타입에 공통으로 있는 프로퍼티/메서드만 바로 쓸 수 있다.
function printId(id: Id) {
console.log(id.toString()) // OK, string 과 number 둘 다 toString 있음
console.log(id.toUpperCase()) // Error: number 에는 없음
}toUpperCase 를 쓰려면 지금 이 값이 string 이라는 걸 컴파일러에게 증명해야 한다. 그게 좁히기다.
실전에서 이런 문자열 리터럴 유니온을 가장 많이 쓰는 곳은 상태값이다. type TurnstileStatus = "disabled" | "loading" | "ready" | "expired" | "error" 처럼 다섯 개짜리 유니온 하나로 위젯 상태를 표현한 예시가 Turnstile 위젯 실전 체크리스트에 나온다. 상태가 string 이 아니라 이 유니온이면, 오타 난 상태값(“readyy” 같은)을 컴파일 타임에 바로 잡아준다.
2-2. 인터섹션: 이것도 저것도
인터섹션은 “여러 타입을 모두 만족” 한다는 뜻이다. 여러 객체 타입을 합칠 때 주로 쓴다.
type WithId = { id: number }
type WithTimestamp = { createdAt: Date; updatedAt: Date }
type Entity = WithId & WithTimestamp
// { id: number; createdAt: Date; updatedAt: Date } 와 동일하게 동작주의할 점 하나. 원시 타입끼리 인터섹션을 만들면 대부분 never 가 된다. string 이면서 동시에 number 인 값은 존재할 수 없기 때문이다.
type Impossible = string & number // never객체 타입을 합성할 때는 유용하지만, 유니온과 헷갈려서 “둘 중 하나” 를 표현하려고 & 를 쓰는 실수가 은근히 잦다. “둘 다” 면 &, “둘 중 하나” 면 | 로 기억해두는 게 낫다.
2-3. typeof 로 좁히기
원시 타입 유니온은 typeof 로 나눈다.
function formatId(id: string | number): string {
if (typeof id === "string") {
return id.toUpperCase() // 이 블록 안에서 id 는 string
}
return id.toFixed(0) // 여기서는 number
}typeof 가닥마다 컴파일러가 타입을 자동으로 좁혀준다. 이걸 타입 가드(type guard) 라 부른다. 런타임 검사 하나가 컴파일 타임 타입 정보로 그대로 이어지는 게 핵심이다. 비교 연산자는 항상 ===를 쓴다. ==은 형변환이 끼어들어 이 좁히기가 기대한 대로 안 될 수 있다 — 자바스크립트에서 ==이 왜 예측 불가능한지는 자바스크립트 기초 6 - 동등 비교와 삼중 등호에서 다뤘다.
2-4. in 연산자로 좁히기
객체 유니온은 typeof 로는 구분이 안 된다. 둘 다 object 이기 때문이다. 이때는 프로퍼티 존재 여부를 보는 in 을 쓴다.
type Circle = { kind: "circle"; radius: number }
type Square = { kind: "square"; side: number }
type Shape = Circle | Square
function area(s: Shape): number {
if ("radius" in s) {
return Math.PI * s.radius ** 2 // s 는 Circle
}
return s.side ** 2 // s 는 Square
}2-5. 태그된 유니온과 discriminant
위 Shape 처럼 각 멤버가 kind 같은 고유한 리터럴 필드를 갖고 있으면, 그 필드 하나로 전체를 스위치할 수 있다. 이걸 태그된 유니온(discriminated union) 이라 부르고, 실무에서 가장 자주 쓰는 좁히기 패턴이다.
function area(s: Shape): number {
switch (s.kind) {
case "circle":
return Math.PI * s.radius ** 2
case "square":
return s.side ** 2
}
}새 도형을 추가하고 switch 에 case 를 빠뜨리면, 함수의 반환 타입 체크에서 s 가 never 로 좁혀지지 않아 컴파일 에러로 드러난다. API 응답 타입을 { status: "success"; data: T } | { status: "error"; message: string } 처럼 설계하면, 프론트 코드가 status 로 분기하는 순간 각 가지에서 정확한 필드만 보이게 할 수 있다.
2-6. 사용자 정의 타입 가드가 필요한 순간
typeof 와 in 으로 안 되는 경우도 있다. 조건이 복잡하거나, 검사 로직을 여러 곳에서 재사용하고 싶을 때다. 이럴 때 is 키워드로 직접 타입 가드 함수를 만든다.
type Envelope = { status: "success"; data: unknown }
function isEnvelope(body: unknown): body is Envelope {
return (
typeof body === "object" &&
body !== null &&
(body as Envelope).status === "success" &&
"data" in body
)
}
function handle(body: unknown) {
if (isEnvelope(body)) {
console.log(body.data) // body 는 여기서 Envelope 로 좁혀진다
}
}isEnvelope 처럼 반환 타입을 body is Envelope 로 선언하면, 이 함수가 true 를 반환하는 분기에서 TypeScript 가 그 값을 실제로 Envelope 로 취급한다. 이 판별 조건을 좁게 설계하지 않으면 다른 문제가 생기는데, 실제로 겪은 사례는 응답 봉투는 어디서 벗기나에 정리해뒀다. 커스텀 타입 가드를 더 깊이 다루는 건 이 시리즈 뒷부분에서 이어간다.
3. 마무리
요약
|는 “이거 아니면 저거”(유니온),&는 “이것도 저것도”(인터섹션). 원시 타입끼리&는 대개never.- 유니온 값은 공통 멤버만 바로 쓸 수 있다. 특정 멤버 타입으로 좁혀야(narrowing) 나머지 API 를 쓸 수 있다.
- 원시 타입은
typeof, 객체는in이나 리터럴 태그 필드(discriminated union)로 좁힌다.- 조건이 복잡하면
is키워드로 사용자 정의 타입 가드 함수를 만들어 재사용한다. 판별 조건은 좁게 설계해야 다른 필드와 오탐이 안 난다.
다음은 3편이다. 제네릭이 실제로 무엇을 해결하는지 다룬다.