5.2 객체 타입의 호환성

타입스크립트가 객체 타입 간의 호환성을 판단하는 법에 대해 다룬다.

다음 두 인터페이스를 보자.

interface User {
  name: string;
  height: number;
}
interface Pet {
  name: string;
  species?: string;
}
const user: User = { name: '안희종', age: 176 };
const puppy: Pet = { name: '해피' };

user의 타입은 User이고, puppy의 타입은 Pet이다. 이 때 아래 코드를 실행했을 때 어떤 결과가 나올까?

const pet2: Pet = user; // ??
const user2: User = pet; // ??

즉, UserPet에 할당 가능할까? 또 반대로 PetUser에 할당 가능할까?

두 가지 기준

타입스크립트가 객체 타입 사이의 할당 가능성을 판단하는 기준은 사실 꽤 간단하다. 타입 AB에 대해, AB에 할당 가능하려면 다음 두 기준을 만족해야 한다.

  • B 타입의 모든 필수 멤버에 대해, A 에도 같은 이름의 멤버가 존재하는가?

  • B 타입과 A 타입에 동시에 존재하는 멤버 m에 대해, A.m 의 타입을 M, B.m의 타입을 M'라 하자. 이 때, 모든 m에 대해서 MM'에 할당 가능한가?

이 때, 두 질문에 대한 답이 모두 “예”라면 AB에 할당 가능하다. 반대로 하나라도 만족되지 않으면, AB에 할당 불가능하다. 예시를 들어 살펴보자.

User는 Pet에 할당 가능한가?

  • Pet의 필수 멤버는 name 하나다. 같은 이름의 멤버가 User에도 존재한다.

  • Petname 멤버 타입은 string 이다(type M = string). Username 멤버 타입 또한 그렇다(type M' = string). 둘은 같은 타입이므로 당연히 서로 할당 가능하다.

두 조건이 모두 만족되므로, UserPet에 할당 가능하다. 이 때 두 가지 눈여겨 볼 점이 있다.

  • Pet에는 Userheight: number 속성이 존재하지 않지만 이는 무관하다. 할당 가능성을 따질 때엔 할당을 받는 쪽의 타입만이 중요하다.

  • 선택 속성으로 정의된 species?: string 은 없어도 무방하다.

Pet은 User에 할당 가능한가?

  • User의 필수 멤버는 nameheight 둘이다. 이 때, Pet 타입엔 height 멤버가 존재하지 않는다.

따라서 두 번째 조건은 볼 것도 없이, PetUser에 할당 불가능하다. const user2: User = pet 과 같은 시도는 타입 에러를 발생시킨다.

구조적 타입 시스템

이렇듯, 타입스크립트에서는 두 타입의 구조(structure)만을 비교하여 호환성을 결정한다. 어떤 타입이 다른 타입이 갖는 멤버를 전부 가지고 있다면 그걸로 충분하며, 두 타입이 호환되는 타입이라는 명시적인 표시는 필요하지 않다. 이렇게 동작하는 타입 시스템을 구조적 타입 시스템(structural type system)이라 부른다.

반대되는 개념으로는 C++, Java 등의 언어가 채택한 노미널 타입 시스템(nominal type system)이 있다. 노미널 타입 시스템을 갖는 언어에서는 특정 키워드를 통해 서로 호환 가능하다고 명시적으로 표현 된 타입 간의 할당만이 허용된다.

객체 리터럴과 과잉 속성 검사

다음 코드를 보자.

interface Color {
  R: number;
  G: number;
  B: number;
}

const white: Color = {
  R: 255,
  G: 255,
  B: 255,
  A: 1
};

변수 whiteColor 타입을 갖는다. 그리고 white에 할당하려는 객체는 R, G, B 세 멤버를 모두 갖고 있고, 세 멤버의 모두 number 타입다. 따라서 위에서 살펴본 구조적 타입 검사에 의하면 이 할당에는 아무런 문제가 없어야 한다.

하지만 실제로 위의 할당은 에러가 발생한다. 에러 메시지는 다음과 같다.

error TS2322: Type '{ R: number; G: number; B: number; A: number; }' is not assignable to type 'Color'.
  Object literal may only specify known properties, and 'A' does not exist in type 'Color'.

객체 리터럴은 알려진 속성(known property)만을 가질 수 있는데, Color 타입에는 A 속성이 존재하지 않으므로 할당이 불가능하다는 메시지이다. 하지만 분명 할당을 받는 쪽의 타입만이 중요하고, 추가적인 멤버를 갖는건 상관 없다고 하지 않았나? 이게 어찌 된 일일까?

이러한 현상이 발생하는 이유는 우리가 할당하는 값이 변수나 표현식이 아닌 객체 리터럴이기 때문이다. 객체 리터럴을 할당하는 경우에는 그 리터럴이 알려지지 않은 속성(unknown property), 즉 할당 받는 타입에 존재하지 않는 속성을 포함한다면 타입 에러가 발생한다.

실제로 위의 코드를 아래와 같이 객체 리터럴이 아닌 변수를 할당하도록 바꾸면 에러는 사라진다.

interface Color {
  R: number;
  G: number;
  B: number;
}

const someColor = {
  R: 255,
  G: 255,
  B: 255,
  A: 1
};
const white: Color = someColor;

이렇게 객체 리터럴에 대해서만 알려지지 않은 속성은 없는지 추가적으로 시행하는 검사를 과잉 속성 검사(excess property checking)라 부른다.

과잉 속성 검사가 존재하는 이유

과잉 속성 검사는 프로그래머의 실수를 막기 위해 존재한다. 어떤 타입의 값에 객체 리터럴을 직접 할당하는 경우, 만약 해당 타입에 정의되지 않은 멤버는 오타 등의 실수로 인해 존재할 확률이 높다고 가정하는 것이다. 공식 문서에서 빌려온 아래 예제를 보자.

interface SquareConfig {
  width?: number;
  color?: string;
}
const squareConfig: SquareConfig = {
  width: 100,
  colour: red
};

구조적 타입 시스템의 원칙에 따르면 위 할당에는 문제가 없다. 하지만 할당 시점에 딱 한 번만 사용될 객체 리터럴에 colour라는 알려지지 않은 속성이 존재한다면, color를 타이핑하려다 오타가 난 경우가 대부분일 것이다. 과잉 속성 검사를 시행해서 이런 흔하게 예상되는 오류를 컴파일러가 잡아 줄 수 있다.

Last updated