구조적 타이핑 언어에서 “정확히 하나만” 이라는 의도를 타입으로 설계 하는 방법
들어가면서
TypeScript에서 흔히 사용하는 유니온 타입(A | B)은
직관적으로는 “A 또는 B”를 의미하지만, 실제로는 Inclusive OR 에 가깝다.
type A = { a: string }
type B = { b: string }
type AB = A | B
const x: AB = { a: "hello", b: "world" } // 에러 아님
TypeScript
복사
처음 타입스크립트를 접할 땐 타입 제약이 기대만큼 명시적으로 표현되지 못한다고 생각했다. TypeScript의 타입 시스템이 잘못된 게 아니라, 의도된 설계다.
이 글에서는
•
왜 이런 일이 발생하는지
•
구조적 타이핑 관점에서 이를 어떻게 이해해야 하는지
•
그리고 never를 활용해 Exclusive OR(XOR) 를 어떻게 구현할 수 있는지
를 원리 중심으로 살펴보겠다.
1. TypeScript는 구조적 타이핑(Structural Typing)을 따른다
TypeScript의 타입 호환성은 이름이 아니라 구조로 결정된다.
type A = { a: string }
type B = { b: string }
const value = { a: "x", b: "y" }
let a: A = value // OK
let b: B = value // OK
TypeScript
복사
이 값은 A에도, B에도 모두 할당 가능하다.
타입 구조적으로 보자면,
“이 객체는 A가 요구하는 구조를 만족한다”
“그리고 B가 요구하는 구조도 만족한다”
즉 구조적 타이핑이다. (덕타이핑에 대한 설명은 다루지 않는다)
2. 유니온 타입이 Inclusive OR가 되는 이유
type AB = A | B
TypeScript
복사
이 타입의 의미는 다음과 같다.
“A로도 사용할 수 있거나, B 로도 사용할 수 있으면 된다”
중요한 포인트는 “둘 중 하나만 가져야 한다”라는 제약은 어디에도 없다는 점이다.
그래서 { a, b }는:
•
A의 요구 조건을 만족하고
•
B의 요구 조건도 만족하므로
→ A | B에 문제없이 할당된다
이건 타입스크립트가 느슨해서가 아니라, 유니온 타입의 정의 자체가 그렇기 때문이다.
3. 우리가 원하는 것은 Inclusive OR가 아니라 Exclusive OR
실무에서는 이런 요구가 자주 등장한다.
•
A 또는 B 중 정확히 하나만
•
둘 다 있으면 안 됨
•
둘 다 없으면 안 됨
예:
// ❌ 허용되면 안 됨
{ a: "x", b: "y" }
// ❌ 허용되면 안 됨
{}
// ✅ 허용
{ a: "x" }
{ b: "y" }
TypeScript
복사
하지만 TypeScript에는 XOR 연산자가 없다.
그래서 우리는 타입 레벨에서 이를 “설계”해야 한다.
Tagged Union이 왜 Exclusive처럼 동작하는가
tagged Union 은
4. never는 “존재하면 안 되는 속성”을 표현한다
여기서 핵심 도구가 never다.
type OnlyA = {
a: string
b?: never
}
type OnlyB = {
b: string
a?: never
}
TypeScript
복사
이 타입이 의미하는 바는 명확하다.
•
b?: never
◦
속성은 있을 수도 있지만
◦
있다면 절대 가질 수 있는 값이 없다
즉, 사실상:
“이 속성은 존재하면 안 된다”
5. 제네릭으로 일반화한 XOR 구현
앞서 살펴본 never 개념적으로 명확하지만, 매번 A와 B를 직접 조합해 작성하기에는 실무에서 한계가 있다.
type OnlyA = { a: string; b?: never }
type OnlyB = { b: string; a?: never }
Plain Text
복사
이 방식은 두 타입이 고정되어 있을 때만 유효하다.
하지만 실제 코드에서는 다음과 같은 요구가 더 흔하다.
•
임의의 두 타입에 대해
•
동일한 XOR 제약을 재사용하고 싶다
•
타입 선언을 매번 새로 만들고 싶지 않다
이 지점에서 제네릭(Generic) 이 필요해진다.
type XOR<A, B> =
| (A & { [K in keyof B]?: never })
| (B & { [K in keyof A]?: never })
TypeScript
복사
사용 예시:
type A = { a: string }
type B = { b: string }
type AB_XOR = XOR<A, B>
const ok1: AB_XOR = { a: "hello" }
const ok2: AB_XOR = { b: "world" }
const err1: AB_XOR = { a: "x", b: "y" } // ❌
const err2: AB_XOR = {} // ❌
TypeScript
복사
6. 왜 이 방식이 동작할까?
TypeScript는 타입 체크 시 다음을 확인한다.
•
각 유니온 멤버에 개별적으로 할당 가능한지
•
교차 타입(&)은 모든 조건을 동시에 만족해야 하는 타입
즉:
A & { b?: never }
TypeScript
복사
은 이런 의미가 된다.
•
a는 반드시 있어야 하고
•
b가 있다면 그 타입은 never여야 한다
객체 리터럴에서 b: "string"을 쓰는 순간,
"string"은 never에 할당 불가능