본문으로 바로가기

타입을 집합으로 바라보기

·
---
·
생각개발

TypeScript의 타입을 집합 관점에서 바라보며 유니온, 인터섹션, keyof가 어떻게 동작하는지 예제와 그림으로 정리해보자

1. 들어가며

이번 글에서는 TypeScript의 타입을 집합 관점에서 정리해보려고 한다.

객체 타입이 어떤 값들을 포함하는지부터 시작해 구조적 타이핑, 유니온, 인터섹션, keyof까지 차례로 살펴본다. 핵심은 타입이 나타내는 값의 집합과 keyof가 나타내는 키의 집합을 구분하는 것이다.

2. 타입은 값의 집합이다

다음 타입을 보자.

type Animal = {
  name: string;
};

Animalname 속성만 가진 객체 하나를 가리키지 않는다. namestring인 모든 값이 Animal에 속한다.

Animal = name이 string인 값들의 집합

따라서 다음 값들은 모두 Animal의 조건을 만족한다.

const dog = {
  name: "호두",
  age: 3,
};

const animal: Animal = dog; // OK

dog에는 age도 있지만 Animal이 요구하는 name: string을 가지고 있다. 객체 타입은 “정확히 이 속성만 있어야 한다”가 아니라 “최소한 이 조건을 만족해야 한다”로 읽으면 된다.

2.1. 구조적 타이핑

TypeScript는 타입의 이름보다 값의 구조를 비교한다.

type Duck = {
  walk(): void;
  quack(): void;
};

const robotDuck = {
  walk() {
    console.log("뒤뚱뒤뚱");
  },
  quack() {
    console.log("꽥꽥");
  },
  charge() {
    console.log("충전 중");
  },
};

const duck: Duck = robotDuck; // OK

robotDuckDuck이라는 이름으로 선언되지 않았다. 그래도 walkquack을 모두 가지고 있으므로 Duck으로 사용할 수 있다. charge가 추가로 있는지는 호환성에 영향을 주지 않는다.

이처럼 필요한 구조를 만족하는지 확인하는 방식을 구조적 타이핑이라고 한다.

2.2. 객체 리터럴은 한 번 더 검사한다

객체 리터럴을 타입이 지정된 위치에 바로 작성하면 초과 속성 검사가 동작한다.

const directAnimal: Animal = {
  name: "호두",
  age: 3, // Error: Animal에 없는 속성
};

반면 값을 변수에 먼저 담으면 구조적 타이핑에 따라 호환성을 판단한다.

const dog = { name: "호두", age: 3 };
const indirectAnimal: Animal = dog; // OK

두 결과가 다른 이유는 추가 속성 자체가 금지되어서가 아니다. 객체 리터럴을 직접 작성하는 위치에서는 오타를 찾기 위한 검사가 한 번 더 적용되기 때문이다.

3. 유니온 타입은 값의 합집합이다

Animal과 함께 사용할 Pet 타입을 하나 정의해보자.

type Pet = {
  owner: string;
  vaccinated?: boolean;
};

type AnimalOrPet = Animal | Pet;

Animal | PetAnimal에 속하거나 Pet에 속하는 값의 집합이다.

Animal | Pet = Animal에 속하는 값 ∪ Pet에 속하는 값
const animalOnly: Animal | Pet = {
  name: "호두",
};

const petOnly: Animal | Pet = {
  owner: "민수",
};

const both: Animal | Pet = {
  name: "호두",
  owner: "민수",
};

세 값은 각각 Animal, Pet, 또는 두 타입 모두의 조건을 만족한다. 따라서 모두 합집합인 Animal | Pet에 들어간다.

유니온 타입의 값을 사용할 때는 실제 값이 어느 쪽인지 아직 알 수 없다.

function printInfo(value: Animal | Pet) {
  value.name; // Error
  value.owner; // Error
}

valueAnimal이면 owner가 없을 수 있고, Pet이면 name이 없을 수 있다. TypeScript는 모든 경우에 존재한다고 보장할 수 있는 속성만 바로 사용하게 한다.

정리하면 유니온은 값의 범위를 넓히지만, 타입을 좁히기 전에 바로 사용할 수 있는 속성은 양쪽에 공통으로 존재하는 것뿐이다.

4. 인터섹션 타입은 값의 교집합이다

이번에는 두 타입을 &로 연결해보자.

type CompanionAnimal = Animal & Pet;

Animal & PetAnimalPet의 조건을 동시에 만족하는 값의 집합이다.

Animal & Pet = Animal에 속하는 값 ∩ Pet에 속하는 값
const companion: CompanionAnimal = {
  name: "호두",
  owner: "민수",
  vaccinated: true,
};

companion에는 name이 있으므로 Animal에 속한다. 동시에 owner가 있으므로 Pet에도 속한다. 하나의 값이 두 집합에 모두 속하기 때문에 교집합에도 속한다.

4.1. 속성 이름이 아니라 값을 교차한다

두 타입의 키만 놓고 보면 공통된 이름이 없다.

Animal의 키 = { name }
Pet의 키    = { owner, vaccinated }

{ name } ∩ { owner, vaccinated } = ∅

하지만 Animal & Pet&는 키의 집합에 적용되지 않는다. 각 타입이 나타내는 값의 집합에 적용된다.

Animal => name을 가진 값들

Pet => owner를 가진 값들

Animal & Pet => name과 owner를 모두 가진 값들

그래서 인터섹션 타입의 객체에는 양쪽 타입이 요구하는 속성이 함께 나타난다. 공통 속성만 남긴 결과가 아니라, 두 조건을 모두 만족하는 값을 구한 결과다.

5. keyof는 키의 집합을 꺼낸다

keyof는 객체 타입에서 사용할 수 있는 키를 유니온으로 만든다.

type Car = {
  brand: string;
  model: string;
  year: number;
};

type CarKey = keyof Car;
// "brand" | "model" | "year"

Car가 자동차 객체들의 집합이라면 keyof Car"brand", "model", "year"라는 키들의 집합이다. 여기서부터는 값의 집합과 키의 집합을 따로 봐야 한다.

5.1. keyof (Animal & Pet)

type IntersectionKey = keyof (Animal & Pet);
// "name" | "owner" | "vaccinated"

Animal & Pet의 값은 두 타입의 조건을 모두 만족한다. 따라서 name, owner, vaccinated를 키로 사용할 수 있다. vaccinated는 선택 속성이지만 키 자체는 존재한다.

결과만 보면 키를 합친 것처럼 보이지만 계산 순서는 다음과 같다.

1. Animal & Pet으로 값의 교집합을 만든다.
2. keyof로 그 값에서 사용할 수 있는 키를 꺼낸다.

5.2. keyof (Animal | Pet)

type UnionKey = keyof (Animal | Pet);
// never

Animal | Pet은 값의 합집합이다. 하지만 keyof (Animal | Pet)은 그 값이 어느 쪽이더라도 안전하게 사용할 수 있는 키를 구한다.

namePet에 없고, ownervaccinatedAnimal에 없다. 공통으로 보장되는 키가 하나도 없으므로 결과는 never다.

공통 키가 있다면 그 키가 남는다.

type IdentifiedAnimal = {
  id: string;
  name: string;
};

type RegisteredPet = {
  id: string;
  owner: string;
};

type CommonKey = keyof (IdentifiedAnimal | RegisteredPet);
// "id"

id는 두 타입에 모두 있으므로 실제 값이 어느 쪽이더라도 안전하게 사용할 수 있다.

표현다루는 대상결과
Animal | Pet두 타입 중 하나를 만족하는 값
Animal & Pet두 타입을 모두 만족하는 값
keyof (Animal | Pet)양쪽에 공통으로 보장되는 키
keyof (Animal & Pet)교집합의 값에서 사용할 수 있는 키

6. 마치며

이번 글을 정리하며 타입을 집합처럼 생각하는 방식은 TypeScript의 여러 연산을 하나의 기준으로 읽게 해준다고 느꼈다.

객체 타입이 어떤 값들을 포함하는지 먼저 생각하면 A | B는 두 집합을 합친 범위로, A & B는 두 조건을 모두 만족하는 범위로 볼 수 있다. 인터섹션 타입에서 양쪽 속성이 함께 나타나는 이유도 이 관점에서는 자연스럽다. keyof가 붙었을 때는 값의 집합이 아니라 키의 집합을 보고 있다는 점만 구분하면 된다.

앞으로 복잡한 타입을 볼 때도 결과부터 외우기보다 “어떤 값이 이 타입에 들어갈 수 있는가?”를 먼저 생각해보려고 한다. 그다음 keyof가 있다면 “이 값에서 어떤 키를 안전하게 사용할 수 있는가?”를 확인하면 된다. 타입을 집합처럼 생각하는 방식은 TypeScript의 타입을 이해하고 설명하는 데 좋은 기준이라고 생각한다.

목차