...
타입의 공변성과 반공변성
타입스크립트는 자바스크립트에 타입을 추가해준 라이브러리 이지만, 타입을 다루는 언어이기도 하다.
그래서 어느새 타입 자체를 코딩하고 있는 자신을 발견하기도 한다.
타입을 코딩하는 도중 많이 당황하는 개념이 타입 간의 관계 이다.
어떤 타입은 다른 타입에 들어가는데 어떤 타입은 안 들어가서 결과가 다르게 나오는 당황스러운 경우가 있기 때문이다.
그리고 여러분들은 이런 이상한 인과를 찾아보다 결국 공변성(Covariance) 이라는 단어까지 도달했을 거라 추측한다.
타입스크립트에는 용어부터 어려워 보이는 이 단어 이외에도 3가지 더 개념이 있는데 간단히 정리하면 다음과 같다.
- 공변성(Covariance) : A가 B의 서브타입이면, T<A>는 T<B>의 서브타입이다.
- 반공변성(Contravariance) : A가 B의 서브타입이면, T<B>는 T<A>의 서브타입이다.
- 이변성(Bivariance) : A가 B의 서브타입이면, T<A> → T<B>도 되고 T<B> → T<A>도 되는 경우
- 불변성(immutability) : A가 B의 서브타입이더라도, T<A> → T<B>도 안 되고 T<B> → T<A>도 안 되는 경우
당최 무슨 소리인가 싶겠지만, 이들 용어는 타입스크립트에서 타입 간에 서로 대입을 할 때 대입이 되는 경우와 대입이 안 되는 경우를 파악하기 위해 어쩔수 없이 알아두어야 한다.
타입스크립트에서의 타입들은 기본적으로 공변성 규칙을 따르지만, 유일하게 함수의 매개변수는 반공변성을 갖고 있다.
단, tsconfig의 strictFunctionTypes 옵션이 true 일때의 기준이다. 만일 strict 모드가 아니라면 함수의 매개변수는 이변성을 갖고 있다.
공변성(Covariance)
- A(좁은 타입)가 B(넓은 타입)의 서브타입이면, T<A>는 T<B>의 서브타입이다.
- A ⊂ B → T<A> ⊂ T<B>
- 대다수 일반적인 경우
let stringArray: Array<string> = [];
let array: Array<string | number> = [];
array = stringArray; // OK - stringArray는 array를 포함
stringArray = array; // Error
// --------------------------------------------------
let subObj: { a: string; b: number } = { a: '1', b: 1 };
let superObj: { a: string | number; b: number } = subObj; // superObj는 subObj 포함
// subObj <: superObj
// 각각의 프로퍼티가 대응하는 프로퍼티와 같거나 서브타입이어야 한다.
우선 기본적으로 생 string 은 유니온 string | number 의 서브 타입이다.
그래서, Array<string> 은 Array<string | number> 의 서브 타입이 된다
그리고 { a: string; b: number } 도 { a: string | number; b: number } 의 서브 타입이 된다.
이렇게 A가 B의 서브타입일 때, T<A> 가 T<B> 의 서브타입이 된다면, T를 공변적이라고 부를 수 있다.
이를 조건부 타입으로 정리해본다면, 다음과 같이 된다.
// 조건부 타입 : T가 P에 속해있으면 ? true : false
type IsSubtypeOf<T, P> = T extends P ? true : false;
type T1 = IsSubtypeOf<Array<string>, Array<string | number>>; // true
type T2 = IsSubtypeOf<Array<string | number>, Array<string>>; // false
type T3= IsSubtypeOf<{ a: string; b: number }, { a: string | number; b: number }>; // true
type T4 = IsSubtypeOf<{ a: string | number; b: number }, { a: string; b: number }>; // false
반공변성(Contravariance)
- A(좁은 타입)가 B(넓은 타입)의 서브타입이면, T<B>는 T<A>의 서브타입이다.
- A ⊂ B → T<B> ⊂ T<A>
- 그런데 위의 공변성 규칙이 함수의 매개변수로 전달된 경우, 이것이 반대로 동작한다.
type Logger<T> = (param: T) => void;
let logNumber: Logger<number> = (param) => {
console.log(param); // number
};
let log: Logger<string | number> = (param) => {
console.log(param); // string | number
};
logNumber = log; // OK
log = logNumber; // Error
// 기본적으로 number <: string | number 지만, 함수 매개변수 제네릭에서는 거꾸로
// Logger<string | number> <: Logger<number>
위의 코드를 보면 인자로 string | number 를 받는 log 함수와, 인자로 number 만을 받는 logNumber 함수가 있다.
원래라면, 공변성의 규칙에 따르면, log 함수는 넓은 타입이고, logNumber는 좁은 타입이니, log = logNumber 식은 성립되야 하지만 에러가 발생한다.
함수의 매개변수의 타입을 다룰경우 반공변성으로 따르기 때문이다.
따라서 logNumber = log 가 성립 된다.
함수에서의 공변성 & 반공변성
- 함수의 리턴값 타입은 공변성
- 함수의 매개변수 타입은 반공변성
- (tsconfig에서 strictFunctionTypes 모드가 적용된 상황)
다음 코드의 함수의 return 타입을 보면 b가 a보다 넓은 타입이다.
좁은 타입을 넓은 타입에 대입할 수 있으니까 number ⊂ number | string 즉, a ⊂ b 가 된다. (공변성)
// 매개변수 타입은 같고, 리턴값 타입이 다를때
function a(x: string): number {
return 0;
}
type B = (x: string) => number | string;
let b: B = a;
반대인 상황 b ⊂ a 에서는 에러가 뜨게 된다.
넓은 타입을 좁은 타입에 대입하려고 하니 문제가 생기는게 당연하다.
function a(x: string): number | string {
return 0;
}
type B = (x: string) => number;
let b: B = a; // string | number' 형식은 'number' 형식에 할당할 수 없습니다.
이번엔 리턴값 타입은 같은데, 매개변수 간의 타입이 다른 경우 이다.
매개변수를 보면 string ⊂ string | number 인 상황이다. 그러면 당연히 a ⊂ b 상황 이어서 b = a 가 될줄 알았지만 오류가 뜬다.
function a(x: string): number {
return 0;
}
type B = (x: string | number) => number;
let b: B = a; // ERROR - string | number' 형식은 'number' 형식에 할당할 수 없습니다.
두 함수 구조를 바꿔보면 대입이 가능해진다.
한마디로 매개변수에서는 (x: string | number) ⊂ (x: string) 이런 로꾸거 구조가 성립 되는 것이다.
function a(x: string | number): number {
return 0;
}
type B = (x: string) => number;
let b: B = a;
조건부 타입으로 리턴값과 매개변수 타입 간의 속함을 비교해보면 다음과 같이 정리된다.
// 조건부 타입 : T가 P에 속해있으면 ? true : false
type IsSubtypeOf<T, P> = T extends P ? true : false;
function param1(x: string): number {
return +x;
}
function param2(x: string | number | boolean): number {
return +x;
}
function return1(x: string): number {
return +x;
}
function return2(x: string): number | string | boolean {
return +x;
}
type T1 = IsSubtypeOf<typeof param1, typeof param2>; // false
type T2 = IsSubtypeOf<typeof param2, typeof param1>; // true
type T3 = IsSubtypeOf<typeof return1, typeof return2>; // true
type T4 = IsSubtypeOf<typeof return2, typeof return1>; // false
매개변수의 반공변성은 매개변수의 갯수 차이가 아닌 매개변수의 타입 갯수(유니온) 에 따른 속성임을 인지하자.
이변성(Bivariance)
TypeScript는 기본적으로는 함수의 인자를 다루는 과정에서 이변성(Bivariance) 구조를 가지고 있다.
즉, 공변성과 반공변성을 동시에 가지고 있고, 그로 인해서 Casting 가능한 어떤 객체든 허용한다.
이런 함수 인자가 이변성 이라는 오류를 바로잡아서 위의 반공변적이게 바꿔주는 옵션이 strictFunctionTypes 이다.
strictFunctionTypes 옵션
tsconfig.json 파일로 가서 다음 주석을 풀고 false로 설정해보자.
그리고 다음 코드를 파일에 쳐보면 에러줄이 뜨지 않는 것을 확인 할 수 있을 것이다.
type A = (p: string) => void;
type B = (p: string | boolean | number[]) => void;
let a: A = (p: string) => {};
let b: B = (p: string | boolean | number[]) => {};
// 서로 매개변수 타입이 다름에 불구하고 막 대입 가능
b = a;
a = b;
위의 예제 처럼, --strictFunctionTypes 옵션을 비활성화 하면 매개변수 타입이 다른 함수끼리 막 대입이 가능해진다.
이것이 이변성이다.
기본적으로 매개변수가 이변성을 가지는 이유는 리턴값은 공변성을 가지는데 매개변수는 반공변성을 가짐으로 인해서 생기는 문제들이 생각보다 많이 때문이다.
예를들어 현실적으로 수많은 @types/ Definition 파일들에 의해 돌아가는 타입스크립트의 특징 상, strictFunctionTypes 를 활성화 시켰을때 발생하는 에러들이 많아지는건 어쩔 수 없다. 그래서 외부 라이브러리를 다루는 과정에서 --strictFunctionTypes 를 활성화 시킨다면 별수 없이 @ts-ignore 를 넣어야 하는 경우가 생긴다.
# 참고자료
https://www.zerocho.com/category/TypeScript/post/5faa8c657753bd00048a27d8
https://dmitripavlutin.com/typescript-covariance-contravariance/
https://seob.dev/posts/%EA%B3%B5%EB%B3%80%EC%84%B1%EC%9D%B4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80/
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.