Typescript + React로 작업을 하다보면, Third Party와 React 모듈에서 'BivarianceHack'이라는 것으로 타입이 정의된 것을 심심찮게 볼 수 있습니다.
BivarianceHack은 Bivariance를 사용할 수 있는 우회 통로입니다.
그렇다면 Bivariance가 뭘까요?
이를 이해하려면 먼저 Covariance와 Contravariance에 대해 이해해야 합니다.
A가 B의 SubType일 때 T<A>가 T<B>의 Subtype인 경우를 의미합니다.
A -> B 이면 T<A> -> T<B>
말로 풀어내면 복잡하지만, 예시를 보면 간단합니다.
먼저 User 클래스와 이를 확장하는 자식 클래스 Admin을 만들어 보겠습니다.
즉, Admin은 User의 Subtype입니다.
class User {
username: string;
constructor(username: string) {
this.username = username;
}
}
class Admin extends User {
isSuperAdmin: boolean;
constructor(username: string; isSuperAdmin: boolean) {
super(username);
this.isSuperAdmin = isSuperAdmin;
}
}
타입스크립트스럽게 이를 확인하기 위해서 IsSubtypeOf라는 헬퍼 타입을 만들어 보겠습니다.
type IsSubtypeOf<A, B> = A extends B ? true : false
type Test1 = IsSubtypeOf<Admin, User>; // true
type Test2 = IsSubtypeOf<User, Admin>; // false
그럼 다시 본론으로 돌아와서, 공변성이 무엇인지 다시 한번 보겠습니다.
A -> B 이면 T<A> -> T<B>
Promise를 통해 예시를 들어보겠습니다.
Promise은 Promise의 Subtype인가요?
다시 말해, 공변적인가요?
type Quiz1 = IsSubtypeOf<Promise<Admin>, Promise<User>> // true
좀 더 쉬운 예시를 들어보겠습니다.
type Quiz3 = IsSubtypeOf<Array<string>, Array<string | number>> // true
Covariance 덕분에 다음과 같은 동작이 가능해 집니다.
let GeneralArray: Array<string | number>;
let stringArray: Array<string> = ['hello', 'variance'];
GeneralArray = stringArray;
반대로 대입하는 것은 불가능하겠죠?
let GeneralArray: Array<string>;
let stringArray: Array<string | number> = ['hello', 'variance'];
GeneralArray = stringArray;
반공변성은 말 그대로 공변성의 반대입니다.
A -> B 이면 T<B> -> T<A>
공변성은 직관적으로 다가오는데, 반공변성은 다소 생소하게 느껴집니다.
기본적으로 타입스크립트는 공변적이라고 볼 수 있습니다. 하지만 몇 가지가 예외적으로 반공변성을 가집니다.
함수가 그렇습니다.
Func라는 함수 타입을 정의해 보겠습니다.
type Func<ParamType> = (param: ParamType) => void;
Func는 공변적일까요 반공변적일까요?
type test1 = IsSubtypeOf<string, string | number>; // true
type test2 = IsSubtypeOf<Func<string>, Func<string | number>>; // ??
정답은 False, 반공변성을 가집니다.
반공변성은 공변성처럼 직관적이진 않은 것 같습니다.
좀 더 구체적인 예시를 들어보겠습니다.
let logNumber = function (param: number) {
console.log(param);
}
let logSomething = function (param: number | string) {
console.log(param);
}
logSomething = logNumber; // Error
logNumber = logSomething; // OK
만약 logSomething = logNumber
가 성립한다면 logSomething
의 인자로 들어오는 숫자와 문자열이 logNumber의 인자로 전달 됩니다.
하지만 logNumber는 number를 인자로 받고 있죠?
그렇기 때문에 함수는 인자에 대해서 반공변적인 것입니다.
이변성은 공변성과 반공변성을 모두 가지는 구조를 의미합니다.
A -> B이면 T<A> -> T<B>와 T<B> -> T<A>가 모두 성립한다.
반공변성의 함수 예시에서 logSomething = logNumber
가 불가능한 이유는 반공변성 때문입니다.
이는 타입스크립트의 strictFunctionTypes
옵션이 기본적으로 true로 설정되어 있기 때문입니다.
해당 옵션을 끄면, 함수는 인자에 대해서 이변성을 가지게 됩니다.
// "strictFunctionTypes": false
type IsSubtypeOf<A, B> = A extends B ? true : false;
let logNumber = function (param: number) {
console.log(param);
}
let logSomething = function (param: number | string) {
console.log(param);
}
logSomething = logNumber; // OK
logNumber = logSomething; // OK
이변성을 허용하면 생각보다 오류가 많이 생길 수 있습니다.
그렇기 때문에 strictFunctionTypes 옵션은 기본적으로 true로 설정되어 있습니다.
BivarianceHack은 strictFunctionTypes
가 true일 때도 함수가 bivariance를 가질 수 있게 해줍니다.
이는 strictFunctionTypes
가 함수에만 적용되고, 객체 메소드에는 적용되지 않는 것을 이용합니다.
Bivariance가 허용되면 오류가 생길 수도 있는데 왜 Hack이 존재하는 걸까요?
Typescript 공식문서의 예시를 보면서 알아 보겠습니다.
enum EventType {
Mouse,
Keyboard,
}
interface Event {
timestamp: number;
}
interface MyMouseEvent extends Event {
x: number;
y: number;
}
interface MyKeyboardEvent extends Event {
keycode: number;
}
function listenEvent(eventType: EventType, handler: (n: Event) => void) {
//..
}
// strictFunctionTypes === true이면 오류!
listenEvent(EventType.Mouse, (e: MyMouseEvent) => console.log(e.x, e.y)); // Error
// 어쩔수 없이 assertion을 사용해야 한다.
listenEvent(EventType.Mouse, (e: Event) => console.log((e as MyMouseEvent).x, (e as MyMouseEvent).y))
listenEvent가 좀 더 유연하게 동작할 수 있게 만들려면 어떻게 해야할까요?
handler를 함수가 아닌 메소드 타입으로 우회하면 가능합니다.
function listenEvent(eventType: EventType, handler: {
bivarianceHack(e: Event): void;
}['bivarianceHack']) {
//
}
//...
listenEvent(EventType.Mouse, (e: MyMouseEvent) => console.log(e.x, e.y)); // OK !