🤧

TypeScript에서의 변성(Variance): 도대체 왜 이렇게 된 거야?

Date
2022/04/01
Tags
TypeScript
Type System
Frontend
Created by
TypeScript Type System의 변성은 대체 왜 이렇게 동작하게 되었을까?
Table of Contents

Prerequisites

타입 시스템에 존재하는 변성(Variance)에 대해 미리 이해하고 있으면 좋다.
또한, TypeScript에서 각각의 경우에 타입들은 어떤 성질을 띄는지 알고 있으면 좋다.
이에 관해서는 다음 글을 참고하면 좋을 수도 있다.

Remind: TypeScript의 Variance

위에서 소개한 글에서 TypeScript에서의 Variance를 다루었지만, 다시 한 번 요약하자면 TypeScript에서의 타입 시스템 동작은 다음과 같이 정리할 수 있다.
기본적으로 Covariant하다.
stringstring | number보다 더 좁은 타입이므로 (stringstring | number), Array<string | number> 타입의 변수에 Array<string> 타입의 변수를 대입하는 것은 문제가 없다. 반대 동작은 당연히 에러가 발생한다.
함수 인수에 대해서는 Contravariant하다.
stringstring | number보다 더 좁은 타입일 때 (stringstring | number), (x: string) => number(x: string | number) => number 관계가 성립한다.
TypeScript에서는 그냥 이게 Rule이며, 각 타입들의 Variance를 직접 설정할 수 있는 방법은 없다. 이는 특정 키워드나 표기법을 이용해 타입의 Variance를 직접 설정할 수 있는 C#, Scala 등의 언어와 비교되는 점이다.
대체 왜? 이렇게 정한 걸까?

최대한 직관적으로 이해하려고 해 보자

TypeScript의 Covariance

일단 TypeScript가 Covariance를 띄는 부분을 먼저 살펴보자.
다음 두 변수가 있다고 가정하자.
string 타입의 변수 a
string | number 타입의 변수 b
ab에 대입 가능하지만 ba에 대입할 수 없는 (Covariance를 띄는) 동작은 어찌 보면 당연해 보인다. string 타입을 string | number에 대입할 수는 있겠지만, string | number 타입을 string 타입에 대입하면... 뭔가 문제가 발생할 것 같지 않은가? 생각해보면 자연스러운 사실이다.

TypeScript의 Contravariance

그렇다면, 함수 인자에 대해서 Contravariance를 띄는 것은 어떻게 설명할 수 있을까?
여기서도 다음 두 함수가 있다고 가정하자.
(x: string) => number 타입의 함수 f1
(x: string | number) => number 타입의 함수 f2
함수 인자에 대해서는 반공변성을 띄므로, 여기서는 반대로 f1f2에 대입할 수 없지만, f2f1에 대입 가능하다.
하지만 생각해보면 당연한 동작이지 않은가? stringnumber를 모두 처리할 수 있는 함수에 string만 처리할 수 있는 함수를 대입할 수 있다면... 뭔가 이상해 보인다. 오히려 그 반대 동작인 ‘string만 처리 가능한 함수를 stringnumber를 모두 처리할 수 있는 함수에 대입 가능한 것’이 오히려 더 자연스러워 보인다.
이렇듯 ‘생각해보면 당연하다’. 위의 예시를 들어 생각해보든 그냥 생각해보든, 함수 인자에 대해서만 Contravariant하게 작동하는 것이 당연해 보인다. 직관적으로는 TypeScript에서 왜 이렇게 설정해 두었는지 알 수 있겠지만, 정말 그 이유에서일까?

왜 별도의 Annotation을 도입하지 않는가?

실제로 TypeScript에 Covariance/Contravariance와 관련해 C#의 in, out 키워드와 비슷한 방식으로 Annotation을 도입하려는 관련 이슈가 남아 있다 (https://github.com/microsoft/TypeScript/issues/1394). 그리고 해당 이슈에 첫 번째로 달린 코멘트는 다음 내용을 담고 있다.
(...) Co/contravariance is not an easy concept for a lot of folks to grasp (even though there is an intuitive nature to the assignment questions). Explicit annotations for them can be especially difficult for people to make use of (particularly if they have to author the annotations in their own code). (...)
대충 해석하자면, 공변성 및 반공변성은 많은 사람들이 이해하기에 그렇게 쉬운 개념이 아니며, 이러한 명시적 Annotation은 많은 사람들이 활용하기에 어려울 수 있다는 내용이다.
그러니까 이 글의 제목이기도 한 “TypeScript에서의 변성은 도대체 왜 이렇게 된 것인가?”에 대한 답변은 “이렇게 하는 게 더 자연스럽고, 많은 사람들이 이해하고 활용하기 더 쉬우므로” 정도가 되겠다.

--strictFunctionTypes를 끄면 함수 인자에 대해 Bivariant하다

대신 TypeScript에서는 --strictFunctionTypes 모드를 도입해, 이 모드가 활성화되었을 때만 함수 인자 타입이 Contravariant하게 작동하도록 하고 있다 (https://github.com/microsoft/TypeScript/pull/18654).
With this PR we introduce a --strictFunctionTypes mode in which function type parameter positions are checked contravariantly instead of bivariantly.
이 모드는 --strict 모드가 활성화되면 자동으로 활성화되며, TypeScript 2.3에서 도입된 --strict는 최신 TypeScript 버전에서 기본 옵션이기 때문에 따로 --strict 옵션을 끄지 않는 이상 함수 인자에 대해서는 Contravariant하게 작동하는 것으로 알면 되겠다.
그럼 해당 모드를 끄면 어떻게 되는가? --strictFunctionTypes 모드를 끄면 함수 인자에 대해 Bivariant하게 작동한다.

함수 인자가 Bivariant? 이건 또 왜?

함수 인자가 Bivariant하게 작동한다니, 이게 어떤 의미를 가지는가? 예시를 하나 들어보자. 다음의 세 함수가 있다고 가정하자.
(x: string) => number 타입의 함수 f1
(x: string | number) => number 타입의 함수 f2
(x: string | number | boolean) => number 타입의 함수 f3
함수 인자에 대해 Bivariance를 띈다면, 세 함수의 인자에 대해 stringstring | numberstring | number | boolean 타입 관계가 성립한다. 따라서, f1, f2, f3 셋을 서로 대입하는 것은 컴파일 에러가 발생하지 않는다.
이는 위에서 우리가 ‘자연스럽게’ 생각했던 것과는 조금 다르다. string, number, boolean 셋을 모두 처리할 수 있는 함수에 string만 처리할 수 있는 함수를 대입할 수 있다니! 런타임에 문제가 생길 여지가 분명하다.
TypeScript가 함수 인자에 대해 Bivariant를 택한 이유는 TypeScript 레포의 Wiki에서 찾을 수 있었다. https://github.com/Microsoft/TypeScript/wiki/FAQ#why-are-function-parameters-bivariant에서 함수 인자가 Bivariance를 띄는 이유를 다음과 같이 설명하고 있다.
두 타입 Dog, Animal에 대해 DogAnimal 타입 관계를 만족한다고 할 때, Dog[]Animal[]의 타입 관계는 어떻게 되는가? 당연히 Dog[]Animal[]이겠지만, 컴파일러는 대략 다음과 같은 계산을 하게 될 것이다.
Dog[]Animal[]에 대입 가능한가? = Dog[]의 각 원소가 Animal[]에 대입 가능한가?
Dog[].push를 Animal[].push에 대입 가능한가?
(x: Dog) => number를 (x: Animal) => number에 대입 가능한가?
...네!
여기서 볼 수 있듯이, 타입 시스템은 “(x: Dog) => number를 (x: Animal) => number에 대입 가능한가?”에 대해 체크하게 된다. 여기서 TypeScript가 strict하게 함수 인자에 대해 Contravariant하게 다룬다면, 이는 곧 DogAnimal을 만족한다는 뜻이므로 DogAnimal에 대입할 수 없게 되고, Dog[] 타입은 Animal[] 타입에 대입할 수 없다는 결론이 나온다.
Dog[]Animal[]에 대입할 수 없다면 어떤 일이 벌어지는가? 우선 듣기만 해도 말이 안 된다는 것을 알 수 있지만, 다음과 같은 코드가 있다고 해 보자.
function checkIfAnimalsAreAwake(arr: Animal[]) { ... } let myPets: Dog[] = [spot, fido]; // 이게 에러가 난다고? Animal[]을 받는 함수 인수로 Dog[]를 넣을 수 없는 거야? checkIfAnimalsAreAwake(myPets);
TypeScript
복사
말이 안 된다. 특히나 함수 checkIfAnimalsAreAwake가 배열을 건드리지 않는 read-only 작업만 한다면 위의 코드는 100% 정상적으로 동작해야 한다.
(...) so we have to take a correctness trade-off for the specific case of function argument types.
결국 TypeScript가 Variance를 표기할 수 있는 별도의 Annotation을 도입하지 않기로 결정하면서 생긴 절충안이라는 것이다. Type safety(Type soundness)를 희생하는 대신 슈퍼타입과 서브타입 관계에 있는 제네릭 등의 메소드에 대해 이를 유연하게 지원하기 위해 Bivariance를 택했다고 보는 것이 가장 합리적인 의견이겠다.

그럼 Variance를 조정하기 위해서는 --strict 옵션까지 만져야 하는 거야?

지금까지의 내용을 요약하자면, 결국 TypeScript는 별도의 Annotation으로 Variance를 조정하는 것을 포기하고 --strictFunctionTypes 옵션을 통해 함수 인자의 Variance를 Contravariance/Bivariance 사이에서 조정할 수 있게 설계되었다.
Type safety 측면에서, 함수 인자를 Bivariant하게 다뤄서 좋을 것은 그다지 없다. 실무에서도 아마 함수 인자가 꼭 이변적으로 동작하게 만들어야 할 타이밍은 많이 없을 것이다. 따라서, 웬만하면 함수 인자를 반공변적으로 다루는 것이 TypeScript의 권장이자 내 의견이기도 하다.
하지만, 위에서 보았듯이 함수 인자가 Bivariance를 띄어야 할 때도 반드시 있다. 생각해보면, 아마 TypeScript 프로그래밍을 하면서 지금까지 --strict 옵션을 켜고 살았을 텐데 왜 위에서 봤던 Dog[]Animal[] 같은 문제가 발생하지 않았을까? 원칙대로라면 --strictFunctionTypes 옵션이 켜져 있으면 (x: Dog) => number를 (x: Animal) => number에 대입할 수 없으므로 Dog[]Animal[]에 대입할 수 없을 텐데 말이다.

Method Shorthand Definition으로 정의하면 메소드 인자에 대해 Bivariant하다

JavaScript에서 Method shorthand definition은 ES6에서 추가된 새로운 문법으로, 말 그대로 메소드를 정의하기 위한 shorthand 문법이다.
const person = { nickname: 'Dogdriip', getNickname1: function() { return this.nickname; }, getNickname2: () => { return this.nickname; }, getNickname3() { return this.nickname; }, };
JavaScript
복사
기존에는 Object에 메서드를 정의하기 위해서 getNickname1getNickname2처럼 코드를 작성해야 했지만, ES6에서는 getNickname3를 정의하는 것과 같이 메소드를 정의할 수 있다.
타입 정의에서도 위와 비슷한 문법으로 메소드 타입을 정의할 수 있다. 가령 다음과 같은 Store 인터페이스가 있다고 가정하자.
interface Store<T> { set1: (item: T) => void; set2(item: T): void; }
TypeScript
복사
set1 메소드와 set2 메소드는 동일한 타입이다. 하지만 set1 메소드의 인자는 Contravariant하게 동작하는 반면 set2 메소드의 인자는 Bivariant하게 작동한다.
다시 정리하자면, 위 코드의 set2와 같이 메소드를 shorthand 문법으로 정의할 경우 메소드의 인자가 Bivariant하게 작동한다는 것이다. 그래서 lib.es5.d.ts 를 실제로 까 보면, Array 의 push 메서드가 shorthand 문법으로 정의되어 있는 것을 확인할 수 있다.
interface Array<T> { // ... push(...items: T[]): number; // ... }
TypeScript
복사
...그래서 Dog[]Animal[]에 대입할 수 있었던 것이다.
이렇게 메소드의 타입 정의 문법에 따라 인자의 Contravariance와 Bivariance를 조절할 수 있게 만든 이유는 아까 잠깐 보았던 https://github.com/microsoft/TypeScript/pull/18654 이슈에서 또 확인할 수 있다.
Methods are excluded specifically to ensure generic classes and interfaces (such as Array<T>) continue to mostly relate covariantly.
그러니까, 앞서 언급했던 Array 등이 공변적으로 동작하도록 만들어 두기 위해서 의도적으로 이렇게 설계한 것이다.

References