자바스크립트에선 변수 하나에 숫자든 문자열이든 아무거나 담았다. 타입스크립트는 그걸 미리 못박는데, 문제는 현실의 값이 "이것일 수도 저것일 수도" 있다는 거다. 로그인은 아이디(숫자)로도 이메일(문자열)로도 하고, 서버 응답은 성공이거나 에러거나 둘 중 하나다. 이 "여러 개 중 하나"를 표현하는 게 유니온이고, 반대로 "이것이면서 동시에 저것"을 합치는 게 인터섹션이다.
둘은 세로줄 하나(|)냐 앰퍼샌드 하나(&)냐로 갈리는데, 이 한 글자 차이가 뜻은 정반대다. 여기서 확실히 갈라놓지 않으면 나중에 타입이 왜 이렇게 나오는지 한참 헤맨다. 아래 결과는 전부 실제 타입 검사기를 돌려 나온 그대로다.
유니온은 이거 아니면 저거다. 타입 여러 개를 |로 이으면, 그 값은 나열된 타입 중 하나면 된다. 아이디를 숫자로도 문자열로도 받고 싶으면 이렇게 쓴다.
let id: number | string;
id = 7; // ok
id = "a7b"; // ok
id = true; // 에러
// Type 'boolean' is not assignable to type 'string | number'.
숫자와 문자열은 통과하지만 불리언을 넣는 순간 타입스크립트가 막는다. 허용한 두 개 말고는 못 들어온다는 뜻이다. |는 나열이라 원하는 만큼 이어 붙일 수 있다.
리터럴 유니온으로 값 자체를 좁힌다. 유니온에는 타입뿐 아니라 딱 정해진 값(리터럴)도 넣을 수 있다. 방향처럼 정해진 몇 개 중 하나만 받고 싶을 때 요긴하다.
type Dir = "up" | "down" | "left" | "right";
function move(d: Dir) { /* ... */ }
move("up"); // ok
move("north"); // 에러
// Argument of type '"north"' is not assignable to parameter of type 'Dir'.
이러면 오타나 엉뚱한 문자열이 애초에 들어갈 수 없다. 자바스크립트에서 문자열 상수로 상태를 관리하다 오타 하나로 조용히 망가지던 걸, 타입스크립트가 편집기에서 빨간 줄로 잡아준다. 나는 상태값을 그냥 string으로 뒀다가 "actived" 같은 오타를 프로덕션에서 만난 뒤로, 이런 자리는 무조건 리터럴 유니온으로 박는다.
유니온은 공통분모만 바로 쓸 수 있다. 여기서 초보가 꼭 데인다. number | string이면 숫자의 기능도 문자열의 기능도 다 될 것 같지만, 반대다. 둘 다에 있는 것만 쓸 수 있다.
function len(x: number | string) {
return x.length; // 에러
}
// Property 'length' does not exist on type 'string | number'.
// Property 'length' does not exist on type 'number'.
length는 문자열엔 있지만 숫자엔 없다. 타입스크립트는 "혹시 숫자가 들어오면 length가 없어 터진다"며 막는다. 지금 x가 둘 중 뭔지 모르니, 양쪽에 다 있는 것만 허락하는 거다. 그럼 각각의 기능은 어떻게 쓰냐면, 타입을 하나로 좁히면 된다.
typeof로 갈라 좁힌다. 조건문으로 지금 값이 무슨 타입인지 확인해주면, 그 블록 안에서는 타입스크립트가 타입을 하나로 확정한다(타입 좁히기).
function toStr(x: number | string): string {
if (typeof x === "number") {
return x.toFixed(2); // 여기선 number로 확정
}
return x.trim(); // 여기선 string으로 확정
}
typeof로 갈라주면 if 안에서는 숫자, 밖에서는 문자열로 타입스크립트가 알아서 좁혀준다. 이 좁히기가 유니온을 실제로 굴리는 핵심이다. 유니온을 만들어만 놓고 이걸 안 하면, 공통분모 밖의 기능은 영영 못 쓴다.
객체 유니온은 in으로 좁힌다. typeof는 number, string 같은 기본 타입을 가를 때 쓴다. 객체 두 종류가 섞인 유니온은 그걸론 안 갈리고, 대신 "이 속성이 있느냐"를 in으로 묻는다.
type Dog = { bark: () => void };
type Cat = { meow: () => void };
function speak(a: Dog | Cat) {
if ("bark" in a) {
a.bark(); // 여기선 Dog로 확정
} else {
a.meow(); // 여기선 Cat으로 확정
}
}
bark라는 속성이 있으면 Dog, 없으면 Cat으로 갈라진다. 이렇게 좁혀두면 if 안에서 a.meow()를 부르려다 실수해도 편집기가 막아준다. 두 객체가 서로 다른 속성을 가졌을 때 쓰는 방식이다.
판별 유니온으로 응답을 안전하게 가른다. 실무에서 가장 많이 만나는 형태다. 각 객체에 status 같은 공통 꼬리표(리터럴)를 하나씩 달아두면, 그 값만 보고 어느 쪽인지 딱 갈린다.
type Ok = { status: "ok"; data: string };
type Err = { status: "error"; message: string };
type Res = Ok | Err;
function handle(r: Res) {
if (r.status === "ok") {
return r.data; // 여기선 Ok로 확정
}
return r.message; // 여기선 Err로 확정
}
status가 "ok"인 블록 안에서는 r이 Ok로 확정돼 data를 꺼낼 수 있고, 밖에서는 Err라 message가 열린다. 만약 좁히기 없이 곧장 r.data를 읽으면 이렇게 걸린다.
function bad(r: Res) { return r.data; }
// Property 'data' does not exist on type 'Res'.
// Property 'data' does not exist on type 'Err'.
Err에는 data가 없으니, 꼬리표로 갈라 Ok임을 확인하기 전엔 못 읽는다. 서버 응답이 성공과 실패로 갈리는 그 구조를 타입으로 그대로 옮긴 것이고, 실무 타입스크립트가 유니온을 쓰는 가장 흔한 이유다.
인터섹션은 이것도 저것도다. 이번엔 앰퍼샌드(&)다. 유니온이 여러 타입 중 하나였다면, 인터섹션은 여러 타입을 전부 합쳐 하나로 만든다. 이름을 가진 타입과 나이를 가진 타입을 합치면, 둘 다 가진 타입이 된다.
type Named = { name: string };
type Aged = { age: number };
type Person = Named & Aged;
const p: Person = { name: "hong", age: 20 }; // ok
// name과 age 둘 다 있어야 한다
Person은 name과 age를 모두 요구한다. 하나라도 빠지면 에러다. 이름이 헷갈리기 쉬운데, 유니온(|)은 선택지가 늘어 느슨해지고, 인터섹션(&)은 요구가 겹쳐 빡빡해진다. 여러 조각 타입을 조립해 큰 객체 타입을 만들 때 이걸 쓴다.
인터섹션끼리 충돌하면 never가 된다. 합치라고 했는데 서로 모순되면 어떻게 될까. 같은 키가 서로 다른 리터럴이면, 그 키는 "둘 다여야 하는데 그런 값은 없는" 상태가 된다.
type A = { kind: "a" };
type B = { kind: "b" };
const x: A & B = { kind: "a" }; // 에러
// Type 'string' is not assignable to type 'never'.
kind는 "a"이면서 동시에 "b"여야 하는데 그런 문자열은 없다. 그래서 타입이 never(어떤 값도 될 수 없는 타입)로 쪼그라들고, 무엇을 넣어도 거부당한다. 나는 두 설정 객체를 &로 합쳤다가 겹치는 키의 타입이 어긋나 온 필드가 never로 죽는 걸 보고, 한참을 왜 멀쩡한 값이 안 들어가나 노려봤다. 합칠 땐 키가 겹치는지부터 본다.
정리하면 |는 OR(여럿 중 하나)라 선택지를 넓히고 대신 공통분모만 바로 쓰게 하며, &는 AND(전부 동시에)라 요구를 합쳐 더 빡빡한 타입을 만든다. 그리고 유니온은 만들어만 둬선 반쪽이고, typeof든 in이든 꼬리표든 좁히기를 거쳐야 비로소 각 타입의 기능이 열린다는 걸 같이 기억해두면 된다.