본문으로 건너뛰기

TypeScript 메서드 bivariance와 배열 공변성 정리

·6 min read

타입이 어떤 방향으로 호환되는지를 가리켜 variance(변성, 하위 타입 관계가 합성 타입의 호환성에 어떻게 전달되는지)라고 한다. TypeScript에서는 같은 함수 타입을 어떤 문법으로 선언했느냐에 따라 매개변수의 variance 검사 방식이 갈린다. 이 차이가 타입은 통과시키면서 런타임에서 터지는 코드를 만들어낸다.

메서드 문법과 함수 프로퍼티 문법은 검사가 다르다

같은 함수를 두 가지 문법으로 선언할 수 있다. 메서드 문법과 함수 프로퍼티 문법이다.

type Animal = { name: string };
type Dog = Animal & { age: number };
 
// 메서드 문법
type Handler1 = { handle(animal: Animal): number };
 
// 함수 프로퍼티 문법
type Handler2 = { handle: (animal: Animal) => number };

두 문법은 비슷해 보이지만 strictFunctionTypes(함수 매개변수를 반공변적으로 엄격하게 검사하는 컴파일러 옵션) 아래에서 매개변수 variance 검사 방식이 다르다.

메서드 문법으로 선언하면 매개변수가 bivariant(공변·반공변 양쪽 모두 허용)하게 검사된다. 그래서 Animal을 받기로 한 자리에 Dog만 받는 구현도 허용된다.

const dogHandler: Handler1 = {
  handle(dog: Dog) {
    return dog.age;
  },
};
 
dogHandler.handle({ name: "cat" }).toFixed(); // 타입 통과, 런타임 위험

{ name: "cat" }Animal이지만 Dog는 아니다. age가 없어 런타임에서 undefined가 반환되고 .toFixed()에서 터진다.

함수 프로퍼티 문법으로 선언하면 strictFunctionTypes에서 매개변수가 더 엄격하게(contravariant, 반공변. 매개변수는 더 넓은 타입만 허용) 검사된다.

const dogHandler: Handler2 = {
  handle(dog: Dog) {  // 에러
    return dog.age;
  },
};
// Type '(dog: Dog) => number' is not assignable to type '(animal: Animal) => number'

같은 코드인데 Handler1(메서드 문법)에서는 통과하고 Handler2(함수 프로퍼티 문법)에서는 에러가 난다.

왜 메서드는 느슨하게 검사되는가

bivariance가 타입적으로 더 안전해서가 아니다. TS 2.6 릴리스 노트가 메서드를 엄격 검사에서 제외한 이유로 드는 건 하나다. Array<T> 같은 제네릭 클래스·인터페이스가 계속 공변적으로(covariant, 하위 타입 관계가 합성 타입에도 그대로 전달되는 방향) 관계 맺도록 유지하기 위해서다.

이들이 타입 매개변수 T를 메서드 매개변수 위치에서 쓰는데, 메서드까지 contravariant로 바꾸면 그 공변 관계가 깨진다. compare(a, b): number 같은 비교 인터페이스 Comparer<T>도 노트가 드는 같은 예시다.

그래서 TS는 함수 프로퍼티는 엄격하게 검사하고, 메서드는 이 공변 관계를 위해 느슨하게 남겼다. 안전한 콜백·핸들러 타입을 원하면 함수 프로퍼티 문법이 낫다.

배열 공변성이 만드는 구멍

variance 차이는 배열에서도 똑같이 구멍을 낸다.

const dogs: Dog[] = [{ name: "coco", age: 5 }];
const animals: Animal[] = dogs;  // Dog[]를 Animal[]에 할당, 공변적 허용
animals.push({ name: "test" });  // Animal[]이므로 통과
 
dogs.forEach((each) => console.log(each.age.toFixed())); // 두 번째 원소에서 터짐

TypeScript는 DogAnimal의 하위 타입이므로 Dog[]Animal[]에 할당할 수 있게 허용한다(공변성). 그런데 배열은 mutable이라, animals를 통해 age 없는 Animal을 push하면 같은 배열인 dogs가 오염된다. 두 번째 원소는 age가 undefined라 each.age.toFixed()에서 런타임 에러가 난다.

안전하게 하려면 readonly를 쓰거나 복사한다. readonly 배열은 push 같은 변경 메서드 자체가 없어서 오염 경로가 막힌다.

const dogs: readonly Dog[] = [{ name: "coco", age: 5 }];
const animals: readonly Animal[] = dogs;
animals.push({ name: "test" }); // Property 'push' does not exist on type 'readonly Animal[]'

복사하면 새 배열이 생기므로 원본 dogs를 건드리지 않는다.

const animals: Animal[] = [...dogs]; // 복사하면 dogs를 오염시키지 않음

메서드 bivariance와 배열 공변성은 둘 다 TypeScript가 완전히 sound한(타입이 통과한 코드는 런타임에서도 안전함이 보장되는) 타입 시스템이 아님을 보여주는 예시다.