Search

inclusive OR에서 never를 활용해 exclusiveOR

구조적 타이핑 언어에서 “정확히 하나만” 이라는 의도를 타입으로 설계 하는 방법

들어가면서

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에 할당 불가능