😮

TypeScript 4.7에 추가된 Type Parameters의 Variance Annotations

Date
2022/08/28
Tags
TypeScript
Created by
TS 4.7부터 Type Parameter에 Variance Annotations를 붙일 수 있게 되었다.
Table of Contents

Prerequisites

타입 시스템에 존재하는 변성(Variance)에 대해 미리 이해하고 있으면 좋다.
TypeScript에서 타입이 어떤 경우에 Covariance를 띄고, 어떤 경우에 Contravariance를 띄는지 이해하고 있으면 좋다.
이에 관해서는 다음 글을 참고하면 좋을 수도 있다.

기존의 TypeScript에서는…

기존의 TypeScript에서, 타입 인자는 기본적으로 Covariant하고 함수 인자에 사용되는 타입 인자에 대해서는 Contravariant했다.
다음 예시를 보자.
interface Animal { animalStuff: any; } interface Dog extends Animal { dogStuff: any; } // ... type Getter<T> = () => T; type Setter<T> = (value: T) => void;
TypeScript
복사
위의 예시에서 Getter의 타입 인자 T는 Covariance를 띄고, Setter의 타입 인자 T는 Contravariance를 띈다. 따라서 TypeScript는:
Getter<Dog> → Getter<Animal> 할당이 valid한지 체크하기 위해 Dog → Animal 할당이 valid한지 체크한다.
Setter<Dog> → Setter<Animal> 할당이 valid한지 체크하기 위해 Animal → Dog 할당이 valid한지 체크한다.

Variance Annotations에 대한 소개

문제는 기존까지의 TypeScript에서는 타입 인자의 Variance를 명시적으로 직접 설정할 수 없었다는 점이다.
--strict 옵션이 켜져 있다는 가정 하에, TypeScript에서 타입들은 기본적으로 Covariant를 띄며, 함수 인자 타입에 대해 Contravariant를 띈다. --strict--strictFunctionTypes 옵션을 꺼서 함수 인자가 Bivariant하게 작동하도록 설정할 수 있다.
TypeScript에도 C#, Scala와 같이 다른 타입 언어처럼 ‘명시적으로’ 타입 인자의 Variance를 표기할 수 있도록 annotation을 두자는 의견이 TS 초기부터 있었다:
그리고 TS 4.7에서 Optional Variance Annotations가 추가되면서 모든 게 해결되었다:
기본적인 Annotation은 in, out, in out 3가지이며, 각각 Generic type argument가 Covariant, Contravariant, Invariant함을 나타낸다.
예를 들어 DogAnimal의 하위 타입일 때 (DogAnimal), 각 표기의 의미와 이를 적용한 모습은 다음과 같다.
의미
TypeScript(4.7+) 표기
공변성(Covariant)
T<Dog>T<Animal>
T<out R>
반공변성(Contravariant)
T<Dog>T<Animal>
T<in R>
무공변성(Invariant)
T<Dog>T<Animal>는 아무 관계가 없다
T<in out R>
얼핏 보았을 때, C#의 Variance annotation과 굉장히 닮아 있다는 것을 알 수 있다. 이 업데이트를 소개한 문서에는 자사 언어인 C#과의 관계에 대해 직접적인 언급은 없지만, 대신 outin이라는 이름의 키워드를 사용한 것에 대해 “Variance에 대해 생각할 필요 없이 Type argument가 output으로 사용될 때 out, input으로 사용될 때 in을 사용하는 것으로 이해하면 된다”고 덧붙였다.
맨 처음에 들었던 예시를 다시 가져와서, GetterT가 Covariant하다는 것을 다음과 같이 out modifier를 붙여 명시적으로 표기할 수 있다.
type Getter<out T> = () => T;
TypeScript
복사
비슷한 방식으로, SetterT가 Contravariant하다는 것을 다음과 같이 in modifier를 붙여 명시적으로 표기할 수 있다.
type Setter<in T> = (value: T) => void;
TypeScript
복사
당연히 Type argument가 input position으로도, output position으로도 사용될 때가 있을 것이다. 이런 경우에는 in out modifier를 적용한 것과 동일하게 판단되며, 타입은 Invariance를 띈다.
interface State<in out T> { get: () => T; set: (value: T) => void; }
TypeScript
복사

표기법까지 ‘굳이’ 도입해야 했는가?

사실 통상적인 구조적 타입 시스템을 채용하는 타입 언어로 프로그래밍하는 사람이라면, 지금까지 Type argument의 Variance에 대해 굳이 신경쓰지 않고 작업해왔을 가능성이 높다. 잘 짜여진 타입 시스템 위에서 타입 파라미터 하나하나의 Variance를 설정할 이유도, 필요성도 ‘굳이’ 없으며, 더 따지자면 타입의 Variance를 결정하는 것은 언어 내지는 컴파일러의 담당 영역이기 때문이다.
Variance annotation이 도입된 것에 대해 몇몇 프로그래머들은 좋아하겠지만, 또 다른 몇몇은 ‘굳이 왜?’라는 생각을 떨치기 어려울 것이다. 지금까지 별도의 annotation 없어도 잘 돌아가도록 언어와 컴파일러를 디자인해놓고, 이제 와서?
TypeScript에서 Variance에 대해 별도의 annotation을 도입하기로 한 이유는 크게 다음과 같다.

이유 1: 코드 독자로 하여금 타입 인자가 어느 용도로 사용되는지 알 수 있도록 하기 위해

이유 중 하나는 명시적인 표기를 통해 코드를 읽는 사람들이 특정 타입 인자가 타입 내부에서 어느 용도로 사용되는지 쉽게 알 수 있도록 하기 위함이다.
복잡한 타입 내에서는 특정 타입 인자가 input position을 갖는지, output position을 갖는지 한 눈에 알기 어렵다. 이를 Annotation으로 표기함으로서 코드 독자들은 해당 타입이 어떻게 사용되는지 알 수 있으며, 개발자 또한 타입 인자의 Variance를 미리 표기해 둠으로서 tsc의 에러를 통해 타입의 Variance가 변경되는 지점을 찾아낼 수 있다.
interface State<out T> { // ~~~~~ // error! // Type 'State<sub-T>' is not assignable to type 'State<super-T>' as implied by variance annotation. // Types of property 'set' are incompatible. // Type '(value: sub-T) => void' is not assignable to type '(value: super-T) => void'. // Types of parameters 'value' and 'value' are incompatible. // Type 'super-T' is not assignable to type 'sub-T'. get: () => T; set: (value: T) => void; }
TypeScript
복사
위와 같은 예시에서, 타입 인자 T가 Getter get()에서 반환형으로만 사용되다가 Setter set()가 추가되면서 T의 변성에 변화가 생긴 상황이라면, 프로그래머는 T가 반환형으로만 사용될 것이라고 예상하고 표기해두었던 out T 덕분에 이를 컴파일 단계에서 알 수 있게 된다. 위와 같은 경우에는 올바르게 고친다면 in out T가 되겠다.

이유 2: 컴파일러의 속도와 정확성을 위해

위에서도 잠깐 언급했지만, TypeScript는 이미 별도의 Variance annotation이 없어도 잘 동작한다. 이는 TS 컴파일러가 타입 체킹을 위해 알아서 타입 인자의 변성을 추론해주고 있기 때문이다. 지금도 tsc의 타입 체크는 충분히 합리적인 시간 내에 수행되지만, 미리 변성을 표기해준다면 컴파일러가 복잡한 타입들에 대한 ‘깊은 비교’를 수행하지 않아도 되고, 컴파일러의 속도 부담을 덜어줄 수 있다.
또한, 변성을 미리 표기해두는 것은 컴파일러의 정확성 향상에도 도움이 된다. 다음 코드를 보자.
type Foo<T> = { x: T; f: Bar<T>; } type Bar<U> = (x: Baz<U[]>) => void; type Baz<V> = { value: Foo<V[]>; } declare let foo1: Foo<unknown>; declare let foo2: Foo<string>; foo1 = foo2; // 에러가 발생해야 하지만 에러가 발생하지 않음 ❌ foo2 = foo1; // 에러가 잘 발생함 ✅
TypeScript
복사
타입 정의만 놓고 본다면 FooBar에 의존하고, BarBaz에, Baz는 다시 Foo 타입에 의존하고 있다. 전형적인 Circular dependency이고, TypeScript 컴파일러는 이를 정확히 체크하지 못한다. Foo<string> 타입의 변수에 Foo<unknown> 타입의 변수를 대입하는 것은 에러로 잘 잡지만(foo2 = foo1) 반대 구문은 에러로 잡지 못한다(foo1 = foo2).
위의 경우에는 말 그대로 순환 참조 상황이고, 명확한 답이 없는 상황이다. 굳이 비유하자면 ‘닭이 먼저냐 달걀이 먼저냐’ 정도의 상황이 되겠다. Foo의 타입 인자 T는 공변성을 띈다고 말할 수 없고, 반공변성을 띈다고 말할 수도 없다. 이렇게 Variance에 대해 명확한 답이 없는 상황에서 다음과 같이 Invariance를 표기해 준다면 적어도 문제가 되는 선언/대입 구문에 대해 정확히 에러를 표시해 준다.
- type Foo<T> = { + type Foo<in out T> = { x: T; f: Bar<T>; } // ... foo1 = foo2; // 에러가 잘 발생함 ✅ foo2 = foo1; // 에러가 잘 발생함 ✅
TypeScript
복사
덧붙여, TypeScript 팀은 이번에 추가된 Variance annotation을 ‘굳이’ 모든 타입 인자에 적용하는 것을 추천하지는 않는다. 굳이 Annotation을 사용할 것이라면 신중하고 정확하게 사용할 것을 권장하고 있다.
필요 이상으로 타입 인자의 Variance를 더 좁게(엄격하게) 정의해야 하는 경우도 분명히 존재한다. 그래서 실제로는 Covariant하지만 Invariant로 표기한 타입 인자의 경우에 대해 TypeScript 컴파일러는 신경쓰지 않는다.

References