타입스크립트의 타입 호환성

타입스크립트의 구조적 서브 타이핑(structural subtyping)을 기반으로 타입의 호환성을 검사합니다. 구조적 타이핑이란 오직 멤버만으로 타입을 관계시키는 방식입니다. 이를 통해 우리는 좀 더 유연하고 편리하게 타입스크립트를 사용할 수 있습니다.

interface Round {
  diameter: number;
}
 
interface Ball extends Round {
  area: number
}
 
const getDiameter = (round:Round) => round.diameter
 
const ball: Ball = { diameter: 10, area: 78.5 };
 
getDiameter(ball) // ok

ballround에 할당할 수 있는지 검사하기 위해, 컴파일러는 round의 각 프로퍼티를 검사하여 ball에서 상응하는 호환 가능한 프로퍼티를 찾습니다. 이 경우, balldiameter라는 문자열 멤버를 가지고 있어야 합니다.

멤버만으로 타입을 관계시켜 필요한 멤버를 갖고 있다면 할당이 된다는 뜻은 특정 타입에 서브 타입 할당이 가능하다는 것과 같습니다. 이러한 특징을 공변성(Covariance)이라고 합니다.

이러한 공변성이 불편하게 다가오는 경우가 있습니다.

공변성의 불편함

타입스크립트는 구조적 서브 타이핑을 기반으로 빌트인 메서드의 타입을 작성하였습니다. 이 과정에서 구조적 서브타이핑이 가질 수 있는 위험성에 대한 고려가 들어가게 되었고, 그로 인해 발생한 불편함을 맞이하게 되었습니다.

대표적인 사례는 Object.keys()입니다.

Object.keys()의 타입 선언은 다음과 같습니다.

interface ObjectConstructor {
    keys(o: object): string[];
}

주목할 부분은 반환 타입인 string[]입니다. 왜 keyof o가 아닐까요?

위 타입은 아래와 같은 불편함을 야기합니다.

Object.keys(ball).forEach(key => ball[key]) // error

string타입의 keyball의 인덱스로 사용될 수 없다는 에러가 발생합니다.

타입스크립트는 왜 이렇게 타입을 작성하였을까요?

타입스크립트의 걱정

타입스크립트는 컴파일 단계에서 발생할 수 있는 에러를 최소한으로 줄여주고자 합니다. 즉, 런타임에서 발생할 수 있는 에러에 대해 상당히 경계합니다.

공변성이 런타임과 무슨 상관이 있을까요? 다음 예시를 보겠습니다.

const fnWhatWeWant = (obj:Round) => {
    return Object.keys(obj) as (keyof Round)[]
}
 
const keys = fnWhatWeWant(ball)
// ok 구조적 서브 타이핑에의해 ball을 허용한다.
// 추론 타입 "disameter"[] !== 실제 타입 ("disameter" | "area")[]

구조적 서브 타이핑을 허용하는 타입스크립트의 입장에서는 객체의 keys를 확정할 수 없습니다. 서브 타입도 얼마든지 허용하기 때문이죠.

이것이 타입스크립트가 걱정하는 구조적 서브 타이핑, 즉 공변성에 대한 우려입니다. 이러한 우려를 줄여줄 수 없을까요?

타입 호환성

지금까지 살펴본 문제는 타입 호환성 중 하나인 공변성 때문에 발생한 문제입니다. 즉, 공변 가능성을 제거하면 우리는 안전하게 타입을 단언할 수 있게 됩니다.

타입 호환성은 원본 타입이 슈퍼 타입, 서브 타입과 호환되는 관계에 따라 4가지 유형이 존재합니다.

  • 공변성
  • 반공변성
  • 무공변성
  • 이공변성

우리는 서브 타입이 호환되는 공변성을 갖는 변수를 원본 타입만 호환되는 무공변성으로 변화시킴으로써 공변 가능성을 제거할 수 있습니다.

무공변성 타입 만들기

학습 과정에서 타입 호환성의 특징을 활용하여 무공변성 타입을 만들 수 있다는 것을 알게 되었습니다. 바로 함수를 활용하는 것입니다. 함수는 공변성 데이터(반환 타입)와 반공변성 데이터(파라미터)가 동시에 존재하는 특징이 있습니다.

다음은 위와 같은 특징을 보여주는 함수의 비교 예시입니다.

interface Super {}
interface Base extends Super {
  key: string
}
 
declare let superParamsFn : (args:Super) => void
declare let basePramsFn : (args:Base) => void
 
declare let superReturnFn : (args:unknown) => Super
declare let baseReturnFn : (args:unknown) => Base
 
superParamsFn = basePramsFn // error
basePramsFn = superParamsFn
 
superReturnFn = baseReturnFn
baseReturnFn = superParamsFn // error

우리는 함수의 파라미터와 반환 타입에 같은 타입을 명시함으로써 무공변성을 만들 수 있습니다.

declare type Invariance<T> = (v: T) => T;
declare let case1: Invariance<Super>
declare let case2: Invariance<Base>
 
case1 = case2 // error
case2 = case1 // error

공변성과 반공변성을 활용하고 있는 하나의 타입이 각각의 조건을 동시에 충족할 수 없기 때문입니다.

이제 우리는 무공변성 브랜드 타입을 만들 수 있습니다. (브랜드 타입이란?)

declare const tag: unique symbol;
 
declare interface InvariantBrand<T> {
  readonly [tag]: (args: T) => T;
}
 
declare type InvariantOf<T> = T & InvariantBrand<T>;
 
const invariantOf = <T>(args: T) => {
  return args as InvariantOf<T>;
};
 
let superType = invariantOf({}) 
let baseType = invariantOf({id:3}) 
 
superType = baseType // error
baseType = superType // error
 
superType = superType // ok
baseType = baseType // ok

드디어 타입스크립트가 걱정하지 않아도 될 무공변성 타입을 만들었습니다. 우리는 이제 안전하게 타입 단언을 사용할 수 있습니다.

const getKeys = <T,>(obj: InvariantOf<T>) => {
  return Object.keys(obj) as (keyof T)[]
}
 
getKeys({id:3}) // error
getKeys(invariantOf({id:3})) // "id"[]

전역 보강

타입스크립트는 전역 보강을 지원합니다. (전역 보강이란?) 따라서 우리가 만든 타입을 빌드인 메서드에 오버로딩할 수 있습니다.

// global.d.ts
 
declare global {
  export interface ObjectConstructor {
    keys<T extends object>(o: InvariantOf<T>): Array<keyof T>;
  }
}
 
export {};
const keys = Object.keys({id:3}) // string[]
const invariantKeys = Object.keys(invariantOf({ id: 3 })); // "id"[]

마치며

명확성이 유연성보다 우선시 되는 상황인 경우 위와 같은 방법을 통해 좀 더 안전하고 뚜렷한 타입을 추론할 수 있게 되었습니다. 이제 상황에 맞게 구조적 타이핑과 명목적 타이핑을 구분하여 타입스크립트의 걱정을 줄일 수 있습니다.

참고