본문 바로가기

유니온과 인터섹션 타입

여러 후보 가운데 하나를 뜻하는 유니온과 여러 요구를 동시에 만족하는 인터섹션을 구분하고, 판별 속성으로 응답을 좁히는 방법과 검증한 입력의 모든 상태를 처리했는지 never로 확인하는 방법을 예제로 따라갑니다.

회원의 ID가 어떤 서비스에서는 문자열이고 다른 서비스에서는 숫자라면, 그 ID를 받는 함수는 두 종류의 값을 모두 받아야 합니다. 반대로 관리자 계정은 일반 회원의 속성과 관리 기능을 함께 가져야 합니다. 타입스크립트는 "이것이거나 저것"을 유니온(union) 타입으로, "이것이면서 저것"을 인터섹션(intersection) 타입으로 나타냅니다.

이 글은 두 타입을 차례로 보고, 서버의 응답처럼 여러 상태 가운데 하나인 값을 판별 속성으로 좁히는 방법을 따라갑니다. 예제는 앞 글처럼 ts-first 프로젝트에 unions.ts를 만들고 include를 그 파일로 바꾸어 확인합니다.

유니온은 허용할 후보를 넓히고 인터섹션은 동시 요구를 적용합니다. 값의 생성·사용·충돌을 구분하며 두 연산을 런타임 객체 병합으로 해석하지 않습니다.

TYPE / RUNTIME / CONTRACT

유니온은 후보, 인터섹션은 동시 요구를 합친다

두 타입의 비교에는 시간 순서가 없습니다. 허용할 값과 사용 가능한 연산, 객체를 만들 때 충족할 요구를 따로 읽습니다. & 연산이 런타임 객체를 병합하지는 않습니다.

UNION / VALUES

UserId = string | number

"abc123"과456789를 모두 허용하고 boolean은 거절합니다. 값을 읽을 때 모든 후보에서 유효한 연산만 바로 사용할 수 있습니다.

INTERSECTION / REQUIREMENTS

Manager = Employee & Person

employeeId/department/name/age를 모두 갖춰야 합니다. 정적 요구를 결합할 뿐 누락된 속성이나 메서드를 런타임에 만들어 주지 않습니다.

UNION / USE

typeof 뒤 문자열과 숫자의 연산 분리

본문 printId는 string이면 toUpperCase, number이면 toFixed(2)를 호출합니다. "uniqueUser123"과789.123의 결과는 각각 대문자 ID와789.12입니다.

INTERSECTION / CONSTRUCT

SuperUser의 데이터와 관리 메서드

User의 id/email과 AdminPrivileges의 두 메서드를 실제 값에 제공합니다. 별칭 선언만으로 관리 권한을 검사하거나 메서드 구현을 생성하지 않습니다.

INTERSECTION / CONFLICT

공통 값이 없으면 never가 될 수 있다

string & number는 never지만 string & "ready"는 "ready"입니다. 판별 속성의 충돌은 객체 전체를 never로 줄일 수 있습니다. 모든 같은 이름 속성의 결합이 실패하는 것은 아닙니다.

STATIC / RUNTIME

응답 선언과 입력 검증은 다른 책임

본문 ApiResponse는 success/error를 status로 좁히지만 data:any를 사용하고 validator와 never 검사는 없습니다. 다음 그림의 확장 설계를 실제 코드 구현으로 해석하지 않습니다.

유니온은 허용할 후보를 넓히고 인터섹션은 동시 요구를 적용합니다. 값의 생성·사용·충돌을 구분하며 두 연산을 런타임 객체 병합으로 해석하지 않습니다.

위 그림처럼 유니온은 받을 수 있는 값의 후보를 넓히고, 인터섹션은 값이 동시에 만족해야 할 요구를 더합니다. 두 연산 모두 타입의 규칙을 조합할 뿐, 실행 중의 객체를 합치거나 바꾸지는 않습니다.

유니온 타입

유니온 타입은 여러 타입을 세로 막대(|)로 이어, 그 가운데 어느 하나의 값이면 된다는 뜻을 나타냅니다. 아래의 type UserId = string | number는 문자열이나 숫자를 받는 타입에 UserId라는 이름을 붙인 것이며, 이렇게 이름을 붙이는 타입 별칭은 다음 글에서 자세히 봅니다.

type UserId = string | number;

const user1Id: UserId = "abc123";
const user2Id: UserId = 456789;

function printId(id: UserId) {
  console.log(`The ID is: ${id}`);
  if (typeof id === "string") {
    console.log(`ID in uppercase: ${id.toUpperCase()}`);
  } else {
    console.log(`ID fixed to 2 decimal places: ${id.toFixed(2)}`);
  }
}

printId(user1Id);
printId(789.123);
The ID is: abc123
ID in uppercase: ABC123
The ID is: 789.123
ID fixed to 2 decimal places: 789.12

UserId에는 문자열과 숫자를 모두 넣을 수 있지만, const user3Id: UserId = true;처럼 불리언을 넣으면 Type 'boolean' is not assignable to type 'UserId'.로 진단됩니다. 함수 안에서 typeof id === "string"이 참인 쪽은 id가 string으로, else 쪽은 남은 number로 좁혀지므로 각 타입의 메서드를 쓸 수 있습니다. 789.123.toFixed(2)는 소수 셋째 자리에서 반올림해 789.12를 돌려줍니다.

좁히지 않은 유니온의 값에는 모든 후보가 함께 가진 연산만 쓸 수 있습니다. printId 안의 if보다 앞에서 id.toUpperCase()를 부르면, 숫자에는 이 메서드가 없으므로 다음과 같이 진단됩니다.

error TS2339: Property 'toUpperCase' does not exist on type 'UserId'.
  Property 'toUpperCase' does not exist on type 'number'.

반대로 toString처럼 문자열과 숫자 모두에 있는 메서드는 좁히지 않아도 쓸 수 있습니다. 같은 이름의 속성이 모든 후보에 있더라도 그 연산이 모든 후보에서 유효해야 하므로, 이름만 같고 매개변수의 규칙이 다른 메서드라면 아무 인수로나 부를 수는 없습니다. 유니온을 좁히는 방법에는 typeof 말고도 instanceof와 in, 직접 만드는 타입 가드가 있으며, 타입 가드와 타입 좁히기에서 자세히 다룹니다.

인터섹션 타입

인터섹션 타입은 여러 타입을 앰퍼샌드(&)로 이어, 그 타입들의 요구를 모두 만족해야 한다는 뜻을 나타냅니다. 객체 타입끼리 이으면 양쪽의 속성을 모두 가져야 하므로, 여러 인터페이스의 속성을 한 타입으로 모을 때 씁니다.

interface Employee {
  employeeId: string;
  department: string;
}

interface Person {
  name: string;
  age: number;
}

type Manager = Employee & Person;

const manager1: Manager = {
  employeeId: "MGR001",
  department: "Sales",
  name: "이지혜",
  age: 45,
};

console.log(manager1.name, manager1.employeeId);
이지혜 MGR001

Manager는 Employee와 Person의 네 속성을 모두 요구하므로, { name: "김철수", age: 30 }만 넣으면 Employee의 employeeId와 department가 빠졌다는 진단이 나옵니다. &는 타입의 요구를 합칠 뿐이어서, 실행 중에 두 객체를 합쳐 주지 않습니다. 이미 있는 두 객체를 실제로 합치려면 { ...employee, ...person } 같은 전개 구문을 따로 씁니다.

같은 이름의 속성이 양쪽에 있으면, 그 속성은 두 타입을 동시에 만족해야 합니다. 동시에 문자열이면서 숫자인 값은 없으므로 string & number는 값이 없는 never가 되고, string & "ready"는 둘을 모두 만족하는 "ready"가 됩니다. { kind: "a" } & { kind: "b" }처럼 판별 속성의 값이 부딪히면, 객체 타입 전체가 never로 줄어들기도 합니다.

판별 속성으로 응답 좁히기

서버의 응답은 성공일 수도 있고 실패일 수도 있으며, 두 경우가 가진 속성도 다릅니다. 이럴 때 두 응답에 같은 이름의 속성 status를 두고, 그 값을 "success"와 "error"처럼 서로 다른 문자열 리터럴(literal) 타입으로 정하면, status만 보고 어느 쪽인지 가릴 수 있습니다.

interface SuccessResponse {
  status: "success";
  data: { user: string };
}

interface ErrorResponse {
  status: "error";
  message: string;
  code: number;
}

type ApiResponse = SuccessResponse | ErrorResponse;

function handleResponse(response: ApiResponse) {
  if (response.status === "success") {
    console.log("데이터를 받았습니다.", response.data.user);
  } else {
    console.log(`오류 발생 (${response.code}): ${response.message}`);
  }
}

handleResponse({ status: "success", data: { user: "Alice" } });
handleResponse({ status: "error", message: "인증 실패", code: 401 });
데이터를 받았습니다. Alice
오류 발생 (401): 인증 실패

status: "success"의 "success"는 문자열 가운데 그 값 하나만 허용하는 리터럴 타입입니다. response.status === "success"가 참이면 response는 SuccessResponse로, 아니면 ErrorResponse로 좁혀지므로, 각 분기에서 그 응답에만 있는 data나 code를 쓸 수 있습니다. 이렇게 유니온의 후보를 가려 주는 속성을 판별 속성(discriminant property)이라 하고, 이런 유니온을 판별 유니온(discriminated union)이라고 합니다.

이 예제는 두 상태만 처리하며, 응답이 정말 이 모양인지 검증하지는 않습니다. 다음 그림은 여기에 로딩 상태를 더해 loading과 success, failure의 세 상태로 나누고, 바깥에서 들어온 값의 검증과 처리하지 않은 상태의 검사를 더한 확장 설계입니다.

검증된 결과와 완전한 상태 처리 — 상태 추적

별도 parseLoadResult/describeResult 실제 확장 코드가 있어 unknown 입력 검증·거절 또는 세 상태의 분기에서 현재 값과 처리 결과를 추적할 수 있다. 상태 이름을 success/error 예제와 섞지 않는다.

입력 검증 뒤 내부 상태를 분기하고 모든 경우의 처리를 확인합니다. 본문의 success·error 코드와 loading·failure·validator·never를 추가한 확장 설계를 구분합니다.

TYPE / RUNTIME / CONTRACT

검증과 판별 상태 처리, 누락 검사를 연결한다

본문의 success/error 예제를 loading/success/failure로 확장한 설계입니다. 외부 입력은 검증을 먼저 거치고, 상태별 처리 뒤 남은 경우가 없는지 정적으로 확인합니다.

입력 검증과 세 상태의 분기unknown→런타임검증의통과·거절분기와, 통과한유니온의loading/success/failure분기다. 8노드7간선이데스크톱과모바일에서같다. 검증거절은끝나며상태분기로연결되지않는다. 기존handleResponse의두상태와본문새parseLoadResult/describeResult의세상태를구분한다.unknown 입력외부 값런타임 검증통과했는가?통과거절입력 거절상태 처리로 넘기지 않음판별 상태 변환LoadResult 계약status분기loadingsuccessfailureloading로딩 안내successdata 사용failuremessage 사용
입력 검증과 세 상태의 분기 · 모바일unknown→런타임검증의통과·거절분기와, 통과한유니온의loading/success/failure분기다. 8노드7간선이데스크톱과모바일에서같다. 검증거절은끝나며상태분기로연결되지않는다. 기존handleResponse의두상태와본문새parseLoadResult/describeResult의세상태를구분한다.unknown 입력모르는 외부 값입력 검증통과했는가?거절통과입력 거절여기서 끝남판별 상태 변환LoadResult 계약status분기loadingsuccessfailureloading로딩 안내successdata 사용failuremessage 사용

EXTENSION / VALIDATE

외부 입력에서 상태 필드를 확인

타입 주석으로 입력을 검증하지 않습니다. status와 각 상태에 필요한 필드, 값 조건을 확인해 내부 유니온으로 변환합니다.

EXTENSION / STATE

상태별 필드만 사용하는 분기

새 확장 코드의 loading은 로딩 안내를, success는 data를, failure는 message를 사용합니다. switch나 상태 비교로 좁힌 범위에서 해당 필드를 사용합니다.

EXTENSION / EXHAUSTIVE

남은 값의 never 할당으로 누락 발견

모든 상태를 처리한 나머지를 never에 할당합니다. 새 상태를 추가하고 처리하지 않으면 진단됩니다. 입력 validator의 정확성을 이 검사 하나로 보장할 수는 없습니다.

ACTUAL LESSON / TWO STATES

원 예제와 확장 코드를 구분

원 handleResponse는 success/error를 비교해 data 또는 message/code를 읽습니다. 이어지는 확장 실습은 parseLoadResult에서 unknown을 검증하고 describeResult에서 loading/success/failure와 switch/never 검사를 실제로 구현합니다.

입력 검증 뒤 내부 상태를 분기하고 모든 경우의 처리를 확인합니다. 본문의 success·error 코드와 loading·failure·validator·never를 추가한 확장 설계를 구분합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명unknown→런타임 검증의 통과·거절 분기와, 통과한 유니온의 loading/success/failure 분기다. 노드 8개·간선 7개가 데스크톱과 모바일에서 같다. 검증 거절은 끝나며 상태 분기로 연결되지 않는다. 기존 handleResponse의 두 상태와 본문의 새 parseLoadResult/describeResult의 세 상태를 구분한다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
외부 입력입력: unknown / 기존 2상태: 이 확장과 별개unknown 값을 parseLoadResult에 전달한다loading·success·failure를 사용하는 확장 예제를 명시한다
검증과 tag거절: 내부 상태 처리 없음 / 통과: LoadResult검증에서 거절되면 끝내고, 통과한 값의 status를 확인한다type assertion만으로 runtime 검증을 대신하지 않는다
완전한 처리결과: 해당 tag 표현 / 누락: 정적 never 진단loading 안내, success data, failure message 중 하나를 고르고 누락은 never로 진단한다세 표현이 순차 실행되는 시간선은 아니다

검증된 결과와 완전한 상태 처리 — 상태 추적

별도 parseLoadResult/describeResult 실제 확장 코드가 있어 unknown 입력 검증·거절 또는 세 상태의 분기에서 현재 값과 처리 결과를 추적할 수 있다. 상태 이름을 success/error 예제와 섞지 않는다.

입력 검증 뒤 내부 상태를 분기하고 모든 경우의 처리를 확인합니다. 본문의 success·error 코드와 loading·failure·validator·never를 추가한 확장 설계를 구분합니다.

TYPE / RUNTIME / CONTRACT

검증과 판별 상태 처리, 누락 검사를 연결한다

본문의 success/error 예제를 loading/success/failure로 확장한 설계입니다. 외부 입력은 검증을 먼저 거치고, 상태별 처리 뒤 남은 경우가 없는지 정적으로 확인합니다.

입력 검증과 세 상태의 분기unknown→런타임검증의통과·거절분기와, 통과한유니온의loading/success/failure분기다. 8노드7간선이데스크톱과모바일에서같다. 검증거절은끝나며상태분기로연결되지않는다. 기존handleResponse의두상태와본문새parseLoadResult/describeResult의세상태를구분한다.unknown 입력외부 값런타임 검증통과했는가?통과거절입력 거절상태 처리로 넘기지 않음판별 상태 변환LoadResult 계약status분기loadingsuccessfailureloading로딩 안내successdata 사용failuremessage 사용
입력 검증과 세 상태의 분기 · 모바일unknown→런타임검증의통과·거절분기와, 통과한유니온의loading/success/failure분기다. 8노드7간선이데스크톱과모바일에서같다. 검증거절은끝나며상태분기로연결되지않는다. 기존handleResponse의두상태와본문새parseLoadResult/describeResult의세상태를구분한다.unknown 입력모르는 외부 값입력 검증통과했는가?거절통과입력 거절여기서 끝남판별 상태 변환LoadResult 계약status분기loadingsuccessfailureloading로딩 안내successdata 사용failuremessage 사용

EXTENSION / VALIDATE

외부 입력에서 상태 필드를 확인

타입 주석으로 입력을 검증하지 않습니다. status와 각 상태에 필요한 필드, 값 조건을 확인해 내부 유니온으로 변환합니다.

EXTENSION / STATE

상태별 필드만 사용하는 분기

새 확장 코드의 loading은 로딩 안내를, success는 data를, failure는 message를 사용합니다. switch나 상태 비교로 좁힌 범위에서 해당 필드를 사용합니다.

EXTENSION / EXHAUSTIVE

남은 값의 never 할당으로 누락 발견

모든 상태를 처리한 나머지를 never에 할당합니다. 새 상태를 추가하고 처리하지 않으면 진단됩니다. 입력 validator의 정확성을 이 검사 하나로 보장할 수는 없습니다.

ACTUAL LESSON / TWO STATES

원 예제와 확장 코드를 구분

원 handleResponse는 success/error를 비교해 data 또는 message/code를 읽습니다. 이어지는 확장 실습은 parseLoadResult에서 unknown을 검증하고 describeResult에서 loading/success/failure와 switch/never 검사를 실제로 구현합니다.

입력 검증 뒤 내부 상태를 분기하고 모든 경우의 처리를 확인합니다. 본문의 success·error 코드와 loading·failure·validator·never를 추가한 확장 설계를 구분합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명unknown→런타임 검증의 통과·거절 분기와, 통과한 유니온의 loading/success/failure 분기다. 노드 8개·간선 7개가 데스크톱과 모바일에서 같다. 검증 거절은 끝나며 상태 분기로 연결되지 않는다. 기존 handleResponse의 두 상태와 본문의 새 parseLoadResult/describeResult의 세 상태를 구분한다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
외부 입력입력: unknown / 기존 2상태: 이 확장과 별개unknown 값을 parseLoadResult에 전달한다loading·success·failure를 사용하는 확장 예제를 명시한다
검증과 tag거절: 내부 상태 처리 없음 / 통과: LoadResult검증에서 거절되면 끝내고, 통과한 값의 status를 확인한다type assertion만으로 runtime 검증을 대신하지 않는다
완전한 처리결과: 해당 tag 표현 / 누락: 정적 never 진단loading 안내, success data, failure message 중 하나를 고르고 누락은 never로 진단한다세 표현이 순차 실행되는 시간선은 아니다

검증한 입력의 모든 상태 처리하기

그림의 흐름을 코드로 옮기면 다음과 같습니다. 앞 예제의 error 대신 failure라는 이름을 쓰는 다른 타입이므로, unions.ts와 섞지 말고 별도의 파일 load-result.ts에서 확인합니다.

load-result.ts
type LoadResult =
  | { status: "loading" }
  | { status: "success"; data: number }
  | { status: "failure"; message: string };

function parseLoadResult(input: unknown): LoadResult | undefined {
  if (typeof input !== "object" || input === null || !("status" in input)) {
    return undefined;
  }
  if (input.status === "loading") return { status: "loading" };
  if (input.status === "success" && "data" in input &&
      typeof input.data === "number" && Number.isFinite(input.data)) {
    return { status: "success", data: input.data };
  }
  if (input.status === "failure" && "message" in input &&
      typeof input.message === "string") {
    return { status: "failure", message: input.message };
  }
  return undefined;
}

function assertNever(value: never): never {
  throw new Error("처리하지 않은 상태");
}

function describeResult(result: LoadResult): string {
  switch (result.status) {
    case "loading": return "로딩 중";
    case "success": return `점수 ${result.data.toFixed(1)}`;
    case "failure": return `실패: ${result.message}`;
    default: return assertNever(result);
  }
}

for (const input of [
  { status: "success", data: 3 },
  { status: "failure", message: "응답 없음" },
  { status: "success", data: "3" },
]) {
  const result = parseLoadResult(input);
  console.log(result === undefined ? "입력 거절" : describeResult(result));
}
점수 3.0
실패: 응답 없음
입력 거절

parseLoadResult는 unknown으로 받은 값이 객체인지, status 속성이 있는지, 상태마다 필요한 속성이 맞는 타입인지를 차례로 확인하고, 통과한 값만 LoadResult의 새 객체로 만들어 돌려줍니다. 세 번째 입력은 data가 문자열 "3"이어서 숫자 검사를 통과하지 못하고 undefined가 되며, 그래서 입력 거절이 출력됩니다. 검증을 통과한 값만 describeResult에 넘어가므로, 그 안에서는 status로 좁힌 속성을 마음 놓고 쓸 수 있습니다.

describeResult의 default에서는 세 상태를 모두 처리하고 남은 result가 never여야 합니다. 그래서 나중에 LoadResult에 새 상태를 추가하고 case를 빠뜨리면, assertNever(result)에 그 상태를 넘길 수 없다는 진단이 나옵니다. 이 검사는 처리하지 않은 상태를 실행 전에 찾아 줄 뿐이고 바깥 값의 검증을 대신하지 않으므로, 그림처럼 검증과 상태 처리를 서로 다른 단계로 둡니다. 이 방법은 공식 Handbook의 누락 검사에 설명되어 있습니다.

기능을 결합하는 인터섹션

인터섹션은 서로 다른 관심사의 타입을 하나로 모을 때도 씁니다. 다음 SuperUser는 일반 회원의 속성과 관리자 기능을 모두 가진 계정입니다.

interface User {
  id: string;
  email: string;
}

interface AdminPrivileges {
  manageUsers: () => void;
  deleteContent: () => void;
}

type SuperUser = User & AdminPrivileges;

const superAdmin: SuperUser = {
  id: "super_admin_001",
  email: "[email protected]",
  manageUsers: () => console.log("사용자를 관리합니다."),
  deleteContent: () => console.log("콘텐츠를 삭제합니다."),
};

console.log(superAdmin.email);
superAdmin.manageUsers();
[email protected]
사용자를 관리합니다.

SuperUser는 User의 id와 email, AdminPrivileges의 두 함수 속성을 모두 요구합니다. 회원의 타입과 관리 기능의 타입을 따로 두고 필요한 곳에서 &로 합치면, 관리 기능이 없는 일반 회원의 타입은 그대로 둔 채 조합만 늘릴 수 있습니다.

더 깊이: 유니온과 인터섹션이 커질 때의 비용

유니온은 쓰기 편하지만 컴파일러에는 공짜가 아닙니다. 공식 저장소의 성능 안내에 따르면, 유니온 타입의 자리에 값을 넘길 때마다 컴파일러는 그 값을 유니온의 후보 하나하나와 비교합니다. 후보가 둘이면 문제가 없지만, 열두 개를 넘으면 컴파일 속도에 실제로 문제가 생길 수 있다고 적혀 있습니다. 또 겹치는 후보를 걸러 내려면 후보끼리 짝지어 비교해야 해서 비용이 후보 수의 제곱으로 늘고, 큰 유니온끼리 인터섹션을 만들면 각 후보마다 분배된 아주 큰 타입이 생겨 이 정리가 더 무거워집니다.

같은 안내는 대안으로, 모든 경우를 유니온으로 나열하는 대신 공통 속성을 가진 바탕 타입을 두고 각 경우를 그 하위 타입으로 만드는 방법을 듭니다. 예를 들어 모든 HTML 요소의 유니온을 받는 대신 공통 멤버를 가진 HtmlElement를 받고, DivElement와 ImgElement가 그것을 확장하게 하는 식입니다. 다만 판별 유니온처럼 경우가 몇 개로 정해져 있고 분기마다 다르게 처리해야 하는 타입이라면, 이 글에서 본 유니온이 여전히 알맞습니다.

인터섹션에는 비용과 다른 문제도 있습니다. 같은 안내는 인터페이스의 extends가 속성을 평평한 객체 타입 하나로 만들면서 속성끼리 부딪히면 그 자리에서 알려 주지만, 인터섹션은 속성을 재귀적으로 합치기만 하므로 부딪힌 속성이 오류 없이 never가 될 수 있다고 설명합니다. 앞에서 본 string & number가 never로 줄어드는 것이 그 예이며, 그래서 이런 실수는 그 타입의 값을 실제로 만들려는 곳에서야 드러납니다.

정리

  • 유니온 A | B는 A나 B 가운데 하나의 값을 받으며, 좁히기 전에는 모든 후보에 공통인 연산만 쓸 수 있습니다.
  • typeof 같은 검사로 유니온을 좁히면, 각 분기에서 그 후보의 속성과 메서드를 쓸 수 있습니다.
  • 인터섹션 A & B는 A와 B의 요구를 모두 만족해야 하며, 부딪히는 속성은 never가 될 수 있고 실행 중의 객체를 합치지는 않습니다.
  • 판별 유니온은 후보마다 서로 다른 리터럴 값을 가진 판별 속성을 두어, 그 값으로 후보를 가립니다.
  • unknown 입력은 먼저 검증해 판별 유니온으로 만들고, 모든 상태를 처리한 뒤 남은 값이 never인지 검사해 빠뜨린 상태를 찾습니다.

연습 문제

1. 새 입력과 새 상태

load-result.ts의 입력 배열에 { status: "loading" }을 추가하면 무엇이 출력되나요? 이어서 LoadResult에 { status: "cancelled" } 후보만 추가하고 두 함수를 그대로 두면, 어디에서 진단이 나오나요?

해설 보기

로딩 입력은 검증을 통과하므로 로딩 중이 출력됩니다. 타입에 취소 후보만 추가하면, describeResult의 default에 남은 result가 { status: "cancelled" }가 되어 assertNever(result)에서 Argument of type '{ status: "cancelled"; }' is not assignable to parameter of type 'never'.가 나옵니다. 이 진단을 없애려면 describeResult에 case "cancelled"를 더하고, 바깥에서 들어오는 취소 입력을 받으려면 parseLoadResult에도 취소 상태를 검증해 돌려주는 분기를 함께 더해야 합니다.

2. 공통 속성과 좁히기

type Pet = { name: string; swim: () => void } | { name: string; fly: () => void };로 선언한 pet에서 pet.name과 pet.swim()은 각각 검사를 통과하나요? swim을 부르려면 어떻게 좁혀야 하나요?

해설 보기

name은 두 후보에 모두 있으므로 pet.name은 통과하지만, swim은 첫 후보에만 있어서 pet.swim()은 TS2339로 진단됩니다. if ("swim" in pet) { pet.swim(); }처럼 in 연산자로 속성이 있는지 확인하면, 그 분기에서 pet이 첫 후보로 좁혀져 swim을 부를 수 있습니다. 두 후보에 판별 속성이 없을 때는 이렇게 한쪽에만 있는 속성으로 좁힙니다.

3. 부딪히는 인터섹션

type Status = { code: string } & { code: number };일 때 Status 타입의 값을 만들 수 있나요? type Ready = { state: string } & { state: "ready" };는 어떤가요?

해설 보기

Status의 code는 string & number, 곧 never가 되므로, code에 넣을 수 있는 값이 없어 Status의 값은 만들 수 없습니다. Ready의 state는 string & "ready"이므로 "ready"가 되고, { state: "ready" }는 할당되지만 { state: "done" }은 진단됩니다. 인터섹션은 속성을 합치는 것이 아니라 양쪽 요구를 동시에 적용한다는 점을 이 두 예가 보여 줍니다.

다음 글 타입 별칭에서는 이 글에서 쓴 type UserId = string | number처럼 타입에 이름을 붙이는 타입 별칭과, 인터페이스와의 차이를 다룹니다.