Curt Poem

프론트 엔드 공부와 지식 나눔을 위한 블로그

개발/TypeScript

타입스크립트 심층 분석 4 - 함수의 포함 관계는 뭔가 다르다. (공변성과 반공변성)

Dovelop 2024. 9. 20. 10:40

시리즈의 다른 글 보기 

이전 글들에서 타입스크립트의 타입 간의 관계는 집합처럼 포함관계를 가진다고 설명했었습니다. 조금 더 전문적인 용어로 서브타이핑 혹은 덕타이핑 방식으로 타입을 판별하기 때문에 어떤 타입이 다른 타입의 요소를 모두 가지면 대체하여 사용하여도 문제가 없는 것으로 봅니다. 특히 서브타이핑의 방식으로 이해하면 서브 타입은 수퍼타입을 대체할 수 있습니다. 이대로 해석하면 어떤 타입이 들어갈 수 있는 자리에는 그 타입의 서브타입을 넣어도 아무 문제가 없어야 합니다. 하지만 다음과 같이 입력값 즉, 함수의 매게변수 사이에서는 더 구체적인 타입을 넣는 것이 허용되지 않습니다.

Typescript Playground에서 보기

interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

let handleAnimal = (animal: Animal) => {
  console.log(animal.name);
};

const handleDog = (dog: Dog) => {
  console.log(dog.name);
  console.log(dog.breed);
};

handleAnimal = handleDog; // Types of parameters 'dog' and 'animal' are incompatible.

공변성(Covariance)

공변성과 관련된 개념은 제네릭 타입 F<T>가 타입 매개변수 T에 따라 어떻게 변화하는지에 대한 개념입니다. 만약 TU의 서브타입일 때 F<T>F<U>의 서브타입이 되는지, 혹은 F<U>F<T>의 서브타입이 되는지, 또는 두 가지 모두 만족하거나 모두 불만족하는지에 대한 말입니다.

 

만약 F<T>T와 같이 동작한다면 (즉, TU의 서브타입일 때 F<T>F<U>의 서브타입이 되면) F<T>F<U>에 대해 공변성을 가진다 혹은 공변한다고 표현할 수 있습니다.

정리하자면

  • TU의 서브타입일 때,
  • F<T>F<U>의 서브타입이 된다면
  • F<T>F<U>에 대해 공변성을 가집니다.
    즉, 공변성은 제네릭 타입이 타입 매개변수의 포함 관계에 따라 같은 방향으로 변화하는 것을 의미합니다.

Typescript Playground에서 보기

type Covariance<T> = () => T;

declare function animalFunction(): Covariance<Animal>;
declare function dogFunction(): Covariance<Dog>

const animalFn: Covariance<Animal> = dogFunction; // OK
const dogFn: Covariance<Dog> = animalFunction; // Type 'Covariance<Animal>' is not assignable to type 'Dog'.

type Check<T, U> = T extends U ? true : false;

type ThisIsTrue = Check<Covariance<Dog>, Covariance<Animal>>
type ThisIsFalse = Check<Covariance<Animal>, Covariance<Dog>>

함수의 반환값은 공변성을 따르기 때문에 DogAnimal의 서브타입이면 Dog를 반환하는 함수는 Animal을 반환하는 함수의 서브타입이 됩니다. 즉 반환값에서는 어떤 타입은 자신의 수퍼타입이 들어가는 자리에 대신하여 들어갈 수 있습니다. 객체의 속성, 클래스의 생성자 또한 공변성을 가지고 있습니다.

 

다만 다음 처럼 리터럴을 통해 값을 선언 또는 변경하는 경우 지난 번에 언급한 것처럼 더욱 엄격한 타입 검사로 인해 오류가 발생할 수 있는데, 오류 메세지에서도 볼 수 있듯이 이는 공변성과 관련한 오류는 아닙니다.

let mutableObj: { name: string } = { name: "Initial Name" };

mutableObj = { name: "curt poem", birthDay: 1021 }; // Object literal may only specify known properties, and 'birthDay' does not exist in type '{ name: string; }'.

const literalObj: { name: string } = { name: "curt poem", birthDay: 1021 };
// Object literal may only specify known properties, and 'birthDay' does not exist in type '{ name: string; }'

반공변성(Contravariance)

반공변성은 이름 그대로 공변성과 반대되는 개념입니다. F<T>T와 반대 방향으로 변화하는 것을 의미합니다. 즉, TU의 서브타입이라면 F<U>F<T>의 서브타입이 됩니다. 함수의 입력값(파라미터, 매개변수)는 타입에 대해 반공변성을 가집니다.(단, strict 모드, 더 정확하게는 --strictFunctionType가 활성화된 경우에 한합니다.)

정리하자면, (strict 모드가 활성화된 경우)

  • TU의 서브 타입일 때,
  • F<U>F<T>의 서브 타입이 된다면
  • F<T>F<U>에 대해 반공변성을 가집니다.

Typescript Playground에서 보기

type Contravariance<T> = (param: T) => void;

declare const animalFunction: Contravariance<Animal>;
declare const dogFunction: Contravariance<Dog>;

// 반공변성: Animal -> Dog는 반대 방향으로 대입 가능
const handleAnimalFunction: Contravariance<Animal> = dogFunction; // Type 'Contravariance<Dog>' is not assignable to type 'Contravariance<Animal>'.
const handleDogFunction: Contravariance<Dog> = animalFunction;

이 코드에서는 함수의 인자가 반공변성을 따르기 때문에, 더 구체적인 타입인 Dog를 매개변수로 가지는 함수가 더 추상적인 타입인 Animal을 매개 변수로 기대하는 자리에 들어갈 수 없고 대신 Animal를 매개변수로 가지는 함수가 Dog를 매개변수로 기대하는 자리에 들어갈 수 있습니다. 이는 클래스 생성자의 매개변수에도 마찬가지입니다.

무공변성과 이변성

공변성 개념과 관련하여 F<T>가 'T'에 대해 공변적이고 반공변적이지도 않은 경우(무공변성, Invariance)와 F<T>T에 대해 공변성과 반공변성을 동시에 가지는 경우(이변성, Bivariance)도 존재합니다.

무공변성(Invariance)

무공변성은 공변성과 반공변성을 가진 타입 함수를 결합할 때 쉽게 만들 수 있습니다.

Typescript Playground에서 보기

type Invariance<T> = (param: T) => T;

declare let animalFunction: Invariance<Animal>;
declare let dogFunction: Invariance<Dog>;

const animalFn: Invariance<Animal> = dogFunction; // Type 'Invariance<Dog>' is not assignable to type 'Invariance<Animal>'.
const dogFn: Invariance<Dog> = animalFunction; // Type 'Invariance<Animal>' is not assignable to type 'Invariance<Dog>'.

이변성(Bivariance)

이공변성은 공변성과 반공변성을 모두 가지는 경우를 의미합니다. 온전한 타입 시스템에서는 이변성을 가지는 경우가 발생하지 않지만 타입스크립트는 완벽한 타입 시스템이 아니기 때문에 예외적으로 발생할 수 있는 경우입니다. 함수가 매개변수로 사용될 때 공변적이면서도 이변적인 경우가 발생할 수 있습니다.

Typescript Playground에서 보기

type Bivariance<T> = {foo(param: T): void};

declare let animalFunction: Bivariance<Animal>;
declare let dogFunction: Bivariance<Dog>;

const animalFn: Bivariance<Animal> = dogFunction; // OK
const dogFn: Bivariance<Dog> = animalFunction; // OK

공변성과 반공변성이라는 서로 다른 기준이 존재하는 이유

타입 시스템은 각 변수에 담긴 자료가 어떤 행동을 할 수 있는 지를 정해놓은 것입니다. 그렇다면 어떤 타입이 될 지 모를 데이터가 함수의 인자나 반환값으로 사용될 때, 확실히 존재하는 행동(메서드)이나 상태를 알려주는 것이 바람직합니다. 그런데 사용처에 따라 확실히 존재할 데이터가 달라질 수 있기 때문입니다. 다음의 예시를 보겠습니다.

class Animal {
  name: string;
}

class Dog extends Animal {
  bark() {
    console.log("멍멍");
  }
}

function getAnimal(): Animal {
  return new Dog();
}

getAnimal함수는 Animal타입을 반환하기로 되어 있습니다. 다시 말해 geAnimal 함수가 반환하는 값은 Animal이 사용가능한 메서드와 상태가 모두 포함되어 있어야 한다는 뜻입니다. Animal자체와 Animal의 서브타입들이 반환되면 이 문제는 해결됩니다. DogAnimal의 서브타입이므로 Animal이 가진 모든 메서드와 상태를 포함하기 때문입니다. 그래서 함수의 반환값은 공변성을 가지는 것이 바람직합니다.

 

그렇다면 함수의 매개변수는 어떨까요?

function processAnimal(animal: Animal) {
  console.log(animal.name);
}

processAnimal함수는 Animal을 매개변수로 받아 사용할 함수입니다. 이 함수가 다른 함수로 대체되어야 한다면 어떤 함수로 대체되어야 할까요? 만약 이 함수가 Animal의 서브타입을 매개변수로 받는 함수로 대체되면 어떻게 될까요?

function processDog(dog: Dog) {
  console.log(dog.bark());
}

만약 함수의 매개변수가 공변성을 가져 위의 함수(processDog)를 processAnimal 대신 넣었다고 생각해 보겠습니다. processDog가 실행 될 때 원래 기대하는 함수는 processAnimal였으므로 Animal타입이 들어올 것입니다. 그런데 Animal에는 bark메서드가 존재하지 않습니다. 오류가 납니다. 이 문제를 해결하기 위해서는 함수는 매개변수로 들어올 인자가 가진 메서드나 상태만을 실행해야 합니다. 그래서 더 넓은 타입을 가진 매개변수가 있는 함수로 대체가능해야 합니다. 따라서 함수의 매개변수는 반공변성을 가지는 것이 바람직합니다. Dog를 인자로 들어 올 함수의 자리에 Animal기대하는 함수를 넣는다면 해당 함수가 실행되는 동안 타입으로 인한 오류가 발생하지 않을 것이기 때문입니다.

 

같인 이유로 함수뿐만 아니라 제네릭 타입에서도 공변성과 반공변성의 특징이 나타납니다.

참고 자료

stack overflow, Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript
타입스크립트의 공변성과 반공변성

위키백과, 공변성과 반공변성 (컴퓨터 과학)