이것 또는 저것 — 유니온 타입
해외여행을 다니다 보면 나라마다 콘센트 모양이 다릅니다. 우리나라는 둥근 두 구멍짜리 플러그를 쓰지만, 영국은 세 구멍짜리, 미국은 납작한 두 구멍짜리입니다. 멀티 어댑터는 이 모든 형태를 하나로 받아낼 수 있습니다. 어떤 플러그가 꽂히든 작동합니다.
TypeScript의 유니온 타입이 바로 그 멀티 어댑터입니다. string | number라고 쓰면 문자열이 와도 되고, 숫자가 와도 됩니다. 둘 중 하나이기만 하면 됩니다.
| 연산자로 유니온 타입 만들기
유니온 타입은 | 기호로 여러 타입을 연결합니다.
let userId: string | number;userId = "user_001"; // 문자열도 되고userId = 42; // 숫자도 됩니다userId = true; // 오류: boolean은 허용되지 않습니다
변수 userId는 문자열이나 숫자 중 하나를 받을 수 있습니다. 다른 타입은 여전히 거부됩니다. 멀티 어댑터라도 지원하는 규격이 정해져 있는 것처럼요.
함수 매개변수에도 유니온 타입을 쓸 수 있습니다.
function printId(id: string | number): void { console.log(`아이디: ${id}`);}printId("user_001"); // 아이디: user_001printId(42); // 아이디: 42
유니온 타입의 실제 사용 — 공통 속성만 접근 가능
유니온 타입에서 한 가지 중요한 규칙이 있습니다. TypeScript는 모든 타입에 공통으로 존재하는 속성과 메서드만 안전하게 사용할 수 있도록 허용합니다.
function getLength(value: string | number): number { return value.length; // 오류! number에는 length가 없습니다}
string에는 length 속성이 있지만 number에는 없습니다. TypeScript 입장에서는 value가 숫자일 수도 있기 때문에, 안전하지 않은 접근을 막습니다.
이 문제를 해결하려면 "지금 이 값이 어떤 타입인지 확인"해야 합니다. 이를 타입 좁히기(type narrowing)라고 하는데, 여기서는 간단히 맛보겠습니다.
function getLength(value: string | number): number { if (typeof value === "string") { return value.length; // 이 블록 안에서 value는 string입니다 } return value.toString().length; // 여기서 value는 number입니다}
typeof value === "string" 조건문을 통과하면, TypeScript는 그 블록 안에서 value가 확실히 string임을 알고 length 접근을 허용합니다. 타입 좁히기의 자세한 내용은 PART 05에서 다룹니다.
문자열 리터럴 유니온
유니온 타입에서 가장 강력하고 자주 쓰이는 패턴이 있습니다. 바로 문자열 리터럴 유니온입니다.
PART 02에서 리터럴 타입을 배웠습니다. "apple"처럼 특정 값 자체를 타입으로 쓸 수 있었죠. 이걸 유니온과 결합하면 "정해진 값들 중 하나만 허용"하는 타입이 됩니다.
type Direction = "north" | "south" | "east" | "west";function move(direction: Direction): void { console.log(`${direction} 방향으로 이동합니다.`);}move("north"); // 정상move("east"); // 정상move("up"); // 오류: "up"은 Direction 타입이 아닙니다
이 패턴은 프로그램의 상태, 이벤트 종류, 버튼 크기 같이 "유효한 값의 범위가 정해진" 경우에 매우 유용합니다.
type Status = "pending" | "active" | "inactive" | "banned";type ButtonSize = "small" | "medium" | "large";type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";function setUserStatus(userId: number, status: Status): void { // status는 반드시 네 가지 값 중 하나입니다 console.log(`사용자 ${userId}의 상태를 ${status}로 변경합니다.`);}setUserStatus(1, "active"); // 정상setUserStatus(2, "deleted"); // 오류: "deleted"는 Status 타입이 아닙니다
오타로 인한 버그를 원천 차단하는 강력한 방법입니다. "acitve" 같은 오타를 냈을 때 JavaScript라면 조용히 넘어가지만, TypeScript는 즉시 오류를 알려줍니다.
인터페이스와 유니온 결합
유니온은 기본 타입뿐만 아니라 인터페이스와도 결합할 수 있습니다.
interface Cat { type: "cat"; name: string; meow(): void;}interface Dog { type: "dog"; name: string; bark(): void;}type Animal = Cat | Dog;function makeSound(animal: Animal): void { if (animal.type === "cat") { animal.meow(); // 이 블록에서 animal은 Cat입니다 } else { animal.bark(); // 이 블록에서 animal은 Dog입니다 }}
type 속성처럼 각 인터페이스를 구분할 수 있는 고유한 값을 "식별자"라고 합니다. 이 패턴은 "판별 유니온(discriminated union)"이라고 불리며, 복잡한 상태를 안전하게 다룰 때 자주 사용됩니다.
모든 인터페이스에 공통인 속성(name)은 타입 확인 없이 바로 접근할 수 있고, 특정 타입에만 있는 속성(meow, bark)은 타입을 좁힌 후에 접근합니다.
유니온으로 API 응답 표현하기
실무에서 자주 마주치는 유니온 타입의 활용 예시를 봅시다. API 호출은 성공할 수도, 실패할 수도 있습니다.
interface SuccessResponse { status: "success"; data: string[];}interface ErrorResponse { status: "error"; message: string; code: number;}type ApiResponse = SuccessResponse | ErrorResponse;function handleResponse(response: ApiResponse): void { if (response.status === "success") { console.log("받은 데이터:", response.data); } else { console.log(`오류 ${response.code}: ${response.message}`); }}
성공했을 때와 실패했을 때의 구조가 다르더라도, 유니온 타입으로 하나의 타입 안에 담을 수 있습니다. status 속성을 식별자로 사용해 두 경우를 구분합니다.
유니온 타입으로 "이것 또는 저것"을 표현하는 법을 배웠습니다. 이번엔 반대 방향으로 가봅시다. "이것 그리고 저것"처럼 두 타입의 모든 속성을 한꺼번에 가져야 하는 경우도 있습니다. 다음 챕터에서는 두 타입을 합쳐 하나로 만드는 인터섹션 타입을 알아봅니다.