타입스크립트의 변성
타입스크립트 사용시 마주하는 에러는 어떤 타입을 다른 타입에 할당할 수 있는지 없는지에 관련되어있어서, 타입을 할당할 수 있는 기준을 알면 대부분의 에러를 해결할 수 있다.
대부분의 타입 시스템이 그렇듯, 타입스크립트도 서브타이핑(=타입 다형성)을 지원하는데, 슈퍼 타입은 서브 타입으로 대체 가능하다. 다르게 말하면 서브 타입을 슈퍼 타입 방향으로 할당할 수 있다.
타입 시스템
C#이나 Java의 타입은 명목적 타입 시스템(Nominal type system)을 따른다. 이 말은 타입의 이름이 다르면 구조가 동일해도 다른 타입으로 취급한다는 뜻이다.
반면, 타입스크립트의 타입 호환성은 구조적 타입 시스템(Structural type system)에 기반하는데, 두 개의 타입을 비교할 때 멤버만을 비교한다는 의미이다. 만약 어떤 객체 A가, 다른 객체 B에 정의된 멤버(프로퍼티, 메서드)를 모두 가진다면, A는 B의 서브타입이다. 따라서 A는 B를 대체할 수 있다. (참고)
변성(Variance)
타입 시스템에는 기본 타입과 복합 타입이 있다. 복합 타입은 기본 타입과 복합 타입을 조합해서 만든 타입을 의미한다. 타입스크립트에선 제네릭을 통해 무수한 복합 타입을 만들어낼 수 있다.
type Composed<T> = { example: T };
type ComposedType1 = Example<number>;
type ComposedType2 = Example<Example<number>>;
Composed<T>
타입은T
라는 타입으로 만들어낸 복합 타입이다.T
는Composed<T>
타입의 컴포넌트(=Composed
타입을 구성하는 요소)이다.
컴퓨터 과학에서의 변성(Variance)은
두 복합 타입간의 서브타입 관계
두 복합 타입의 컴포넌트간의 서브타입 관계
의 관련성을 설명하는 용어이다. (참고)
대상이 4개라서 생각하기가 쉽지 않은데, 위의 코드로 예시를 들자면, Composed<A>
와 Composed<B>
의 서브타입 관계와, A
와 B
의 서브타입 관계의 관련성을 나타내는데 사용할 수 있다.
공변, 반변
복합 타입 Composed<T>
에 대해서
A
가 B
의 서브타입일 때, Composed<A>
가 Composed<B>
의 서브타입이라면 Composed<T>
라는 타입은, T
에 대해 공변(Covariant)한다고 말한다. A → B라는 서브타입/슈퍼타입 관계를 나타내는 방향이, 복합타입에서도 Composed<A>
→ Composed<B>
로 유지되기 때문에 이렇게 표현한다.
반대로 A → B라는 서브타입/슈퍼타입 관계가 복합타입에서 Composed<B>
→ Composed<A>
로 뒤집히는 경우가 있다. 이 때는 Composed<T>
라는 타입은 T
에 대해 반변(contravariant)한다고 말한다.
이 외에도 공변하면서 반변하는, Composed<A>
→ Composed<B>
를 만족하면서 Composed<B>
→ Composed<A>
를 만족하는 이변(bivariant)과
공변하지 않고, 반변하지도 않는 무공변(invariant)이 있다.
공변 타입 예시
대부분의 복합 타입은 공변성을 가진다. 예를들어 Promise<T>
타입의 경우, 두 타입 인자인 number
와 number | string
의 서브타입 관계가 Promise<number>
와 Promise<number | string>
의 서브타입 관계와 동일한 방향이다.
number
→number | string
Promise<number>
→Promise<number | string>
이건 유틸리티 타입을 만들어서 확인해볼 수 있다.
type IsSubTypeOf<Sub, Super> = Sub extends Super ? true : false;
type A = number;
type B = number | string;
// true
type Result1 = IsSubTypeOf<A, B>;
// true
type Result2 = IsSubTypeOf<Promise<A>, Promise<B>>;
// false
type Result3 = IsSubTypeOf<Promise<B>, Promise<A>>;
반변 타입 예시
프로퍼티 키의 타입을 파라미터로 받아서, 객체 타입을 생성하는 복합타입을 생각해볼 수 있다.
type Obj<K extends PropertyKey> = Record<K, number>;
type A = 'a' | 'b';
type B = 'a' | 'b' | 'c';
const obj1: Obj<A> = { a: 1, b: 2 };
const obj2: Obj<B> = { a: 1, b: 2, c: 3 };
이 예시도 서브타입 체크를 해보면 Obj<K>
타입이, K
에 대해서 반변한다는 것을 알 수 있다.
type Obj<K extends PropertyKey> = Record<K, number>;
type A = 'a' | 'b';
type B = 'a' | 'b' | 'c';
// true
type Result1 = IsSubTypeOf<A, B>;
// false
type Result2 = IsSubTypeOf<Obj<A>, Obj<B>>;
// true
type Result3 = IsSubTypeOf<Obj<B>, Obj<A>>;
함수
함수를 타입으로 표현할 때, 파라미터 타입과 반환타입을 제네릭으로 표현할 수 있다.
type Fn<P, R = void> = (p: P) => R;
여기서 함수라는 복합 타입은 파라미터 타입인 P
에 대해 반변하고, 리턴 타입인 R
에 대해서는 공변하는 특징이 있다. (참고)
type IsSubTypeOf<Sub, Super> = Sub extends Super ? true : false;
interface AB {
a: string;
b: string;
}
interface ABC extends AB {
c: string;
}
type Fn<P, R = void> = (p: P) => R;
type FnAB = Fn<AB>;
type FnABC = Fn<ABC>;
type Result1 = IsSubTypeOf<ABC, AB>; // true (ABC -> AB)
type Result2 = IsSubTypeOf<Fn<AB>, Fn<ABC>>; // true (함수의 파라미터)
type Result3 = IsSubTypeOf<Fn<ABC>, Fn<AB>>; // false (함수의 파라미터)
type Result4 = IsSubTypeOf<Fn<any, ABC>, Fn<any, AB>>; // true (함수의 리턴)
type Result5 = IsSubTypeOf<Fn<any, AB>, Fn<any, ABC>>; // false (함수의 리턴)
함수의 파라미터가 반변성을 가지는건 다음 예시를 통해 설명할 수 있다.
interface AB {
a: string;
b: string;
}
interface ABC extends AB {
c: string;
}
type Fn<P, R = void> = (p: P) => R;
type FnAB = Fn<AB>;
type FnABC = Fn<ABC>;
let fnAB: FnAB = (p) => {
console.log(p.a, p.b)
};
let fnABC: FnABC = (p) => {
console.log(p.a, p.b, p.c)
};
fnAB = fnABC; // 에러가 발생할까?
서브 타입 관계는 ABC
→ AB
인 상태인데, 이 때 fnABC
→ fnAB
를 만족할까? fnABC
를 fnAB
에 할당할 수 있으면 만족하겠지만 실제로는 불가능하다.
fnAB
를 사용하는 측에선 파라미터 타입을 AB
로 인지한다. 따라서 함수 fnAB
를 호출할 때 인자로 a
와 b
프로퍼티가 존재하는 객체를 전달하게 된다. a
와 b
만 포함하면 그 외의 프로퍼티는 전달하든 안 하든 상관이 없다.
반면 fnABC
는 함수에서 c
프로퍼티를 사용하고 있다. 따라서 호출할 때 반드시 프로퍼티 c
가 포함된 객체를 전달해야 한다.
따라서 fnABC
는 fnAB
에 할당할 수 없다. fnABC
는 반드시 c
프로퍼티를 전달해야 하는데, fnAB
에 할당된 fnABC
를 사용할 때는 c
프로퍼티를 전달할 수도 있고 아닐 수도 있기 때문이다.
반대로 fnAB
를 fnABC
에 할당하는건 가능하다. fnABC
를 사용하는 쪽에서는 반드시 a
, b
, c
프로퍼티가 모두 포함된 객체를 전달할 것임이 보장된다. fnAB
에서 필요한 모든 프로퍼티를 fnABC
를 호출할 때 전달할 수 있으므로 Type Safe하다.
파라미터는 유저 입장에서 input으로 활용하기 때문에 그림으로 다음과 같이 표현할 수 있다.
좁은 파라미터 타입의 함수를 넓은 파라미터 타입의 함수로 대입하는 경우, 좁은 파라미터 타입의 함수로는 대응할 수 없는 파라미터가 인풋으로 전달될 수 있기 때문에 안전하지 않다.
그러므로 더 넓은 파라미터 타입을 갖는 함수 → 더 좁은 파라미터 타입을 갖는 함수 방향으로 대입할 수 있는 것이다.
할당 가능성으로 서브 타입 관계를 판단할 수 있으니, 함수의 파라미터는 더 넓은 범위의 파라미터가 더 좁은 범위의 파라미터의 서브 타입이 된다는 결론이다.
이 특징을 이용하면 어떤 함수든 대입할 수 있는 함수 타입을 만들 수 있다. 위 그림에서 유저 인풋으로 들어갈 수 있는 타입을 극단적으로 좁히면 되는데, Bottom타입으로 불리는, 집합으로 바라볼 때 공집합이라고 할 수 있는 never타입을 사용하면 된다.
type Fn = (...p: never) => void;
const fn1: Fn = (a: number) => {};
const fn2: Fn = (a: number, b: string, c: symbol) => {};
그림으로 표현하면 다음과 같다. 점으로 표현했지만 공집합이라 사실은 아무것도 없는게 맞다.
공변 타입과 반변 타입 만들기
타입 파라미터로 T를 받는 복합 타입(제네릭)을 작성할 때, 임의의 두 타입 인자에 대해서 두 복합 타입의 서브 타입 관계가 유지되도록 만들면 공변 타입이고, 반대가 되면 반변 타입이 된다.
위에서 살펴본 예시를 기반으로 생각해보면, T를 함수의 파라미터나 객체의 키로 활용하면 반변 타입이 될 것이고, 그 외의 대부분은 공변 타입이 될 것이다.
// Example1는 T에 대해 공변한다.
type Example1<T> = {
a: (a: number) => T;
}
// Example2는 T에 대해 반변한다.
type Example2<T> = {
a: (a: T) => number;
}
// Example3는 T에 대해 반변한다.
type Example3<T extends PropertyKey> = {
[key in T]: any;
};
타입스크립트 4.7부턴 공변과 반변을 명시할 수 있는 문법을 지원한다. 읽는 사람이 타입만 보고 파라미터가 어떻게 사용되는건지 명시적으로 알 수 있도록 하거나, 타입스크립트가 변성을 추론하는 알고리즘을 일부 건너뛸 수 있기에 타입 검사속도를 빠르게 만들 때 사용할 수 있다. (참고)
사용법은 제네릭 G<T>
가 있을 때, 타입 파라미터 T
앞에 out
과 in
을 붙이면 되는데, 복잡하게 생각할것 없이 파라미터 T가 input으로 사용되는지(in
) output으로 사용되는지(out
) 판단해서 작성하면 된다고 한다. 만약 반변 타입에 out을 쓰거나 공변 타입에 in을 쓰면 에러가 발생한다. (참고)
// Example1는 T에 대해 공변한다.
type Example1<out T> = {
a: (a: number) => T;
}
// Example2는 T에 대해 반변한다.
type Example2<in T> = {
a: (a: T) => number;
}
// Example3는 T에 대해 반변한다.
type Example3<in T extends PropertyKey> = {
[key in T]: any;
};
그럼 다음 처럼 타입 파라미터 T가 입력 위치에서도 사용되고 출력 위치에서도 사용되면 어떻게 될까?
type Example<T> = {
a: (a: T) => any;
b: (b: any) => T;
}
이 경우는 무공변이 되어, 서로다른 두 타입 Example<A>
, Example<B>
에 대해 A와 B가 동일하지 않으면 서로에게 할당할 수 없다. 이 경우엔 in, out키워드를 둘 다 사용해서 표기할 수 있다.
type Example<in out T> = {
a: (a: T) => any;
b: (b: any) => T;
}
완벽하지는 않은 타입스크립트
타입스크립트에서는 클래스를 타입으로 사용할 수 있다. 단, 이 경우 클래스 자체의 타입이 아니라 인스턴스의 타입이 된다.
const arr: Array<unknown> = [];
Array
타입은 공변적으로 동작할까?
type IsSubTypeOf<A, B> = A extends B ? true : false;
type Obj = { prop: number };
type A = number;
type B = number | Obj;
// true
type Result1 = IsSubTypeOf<A, B>;
// true
type Result2 = IsSubTypeOf<Array<A>, Array<B>>;
// false
type Result3 = IsSubTypeOf<Array<B>, Array<A>>;
직접 검증해보면 Array<T>
는 파라미터 T
에 대해 공변한다는 것을 확인할 수 있고, 직관적으로는 잘 받아들여진다.
그런데 이 동작이 Type Safe한건 아니다. (참고)
type IsSubTypeOf<A, B> = A extends B ? true : false;
type Obj = { prop: number };
type A = number;
type B = number | Obj;
// Array<T>는 T에 대해 공변
// 따라서 A -> B이면, Array<A> -> Array<B>이므로
// Array<A> 타입을 Array<B> 타입에 할당할 수 있다.
const a: Array<A> = [1, 2, 3];
const b: Array<B> = a;
b.push({ prop: 1 });
console.log(a.at(-1)); // { prop: 1 }
// a는 Array<number>인데,
// { prop: number }를 엘리먼트로 갖게 되었지만, 오류를 표시해주지 않는다.
const add = (a: number, b: number) => a + b;
const num = a.pop();
if (num) {
add(num, 200); // "[object Object]200"
}
객체 A를 객체 B에 할당할 수 있으려면 A의 모든 멤버를 B의 모든 멤버에 할당할 수 있어야 한다. 위 예시에서 Array<A>
는 Array<B>
에 할당할 수 있다고 판단하여 따로 오류가 표시되지 않은 것인데 실제로는 오류가 발생할 수 있는 코드다.
직관적으로 받아들여진 이유는 Array
타입을 배열 값으로만 생각하기 때문이다. 그런데 Array
타입은 반변으로 동작해야 Type Safe한 멤버를 가지고 있다. Array
의 ES5 타입 선언 파일중 일부를 살펴보면 알 수 있다.
interface Array<T> {
length: number;
...
pop(): T | undefined;
push(...items: T[]): number;
unshift(...items: T[]): number;
...
}
pop
과 같이 output으로 사용되는 T
도 있고, push
나 unshift
같이 input으로 사용되는 T
도 있다. 따라서 Array
는 무공변으로 동작하는게 Type Safe하다.
그런데 Array<SubType>
에서 Array<SuperType>
으로의 할당은 어떻게 가능한 것일까?
이유는 메서드 타입 표기법에 있다.
method: (...p: T) => void;
method(...p: T): void;
타입스크립트에선 위와 같이 프로퍼티로 메서드를 표현하는 경우, 복합 타입이 메서드의 파라미터에 대해 이변적으로 동작하고, 아래와 같이 단축 표기법으로 사용하는경우엔 반변적으로 동작한다.
Array의 메서드는 단축 표기법으로 타입이 작성되어있기 때문에, 메서드의 파라미터 자리에 서브타입이 들어와도 오류가 발생하지 않는 것이다. (원래라면 함수의 파라미터에는 슈퍼 타입이 들어와야 Type Safe하다는걸 위에서 확인했다.)
즉, 원래라면 Array<T>
는 T
에 대해 무공변한데, Type Safety를 일부 포기하고 공변적으로 동작하도록 예외를 둔 것이다.
Methods are excluded specifically to ensure generic classes and interfaces (such as
Array<T>
) continue to mostly relate covariantly. The impact of strictly checking methods would be a much bigger breaking change as a large number of generic types would become invariant (even so, we may continue to explore this stricter mode).(출처)
실제로 Array<T>
에 있는 메서드를 전부 프로퍼티 표기법으로 바꾼 뒤, 타입 파라미터 T
에 in, out 키워드를 차례대로 넣어보면 둘 다 오류가 발생한다.
위키피디아에서도, 프로그래밍 언어에서 타입 시스템을 간결하게 유지할 목적으로 Type Safety를 포기하고 타입 생성자를 공변적으로 취급할 수 있음을 언급하고 있다.
In order to keep the type system simple and allow useful programs, a language may treat a type constructor as invariant even if it would be safe to consider it variant, or treat it as covariant even though that could violate type safety.
(출처)
아래는 공부하면서 특히 도움이 되었던 문서 및 블로그 링크다.