Typescript의 변성(Variance)이 뭐지?

Typescript의 변성(Variance)이 뭐지?

Tag
Typescript
Abstraction
첫 포스팅을 무엇으로 할까 매우 고민했는데, 최근 관심있는 추상화와 관련된 내용인 타입 변성에 대한 내용을 가지고 오게 되었다.

LSP ( Liskov Substitution Principle )

제목과 다르게 이상한 용어가 나와 당황할 수 있다.
LSP 는 SOLID 에서 L 에 속하는 리스코프 치환 원칙이다.
 
그렇다면 내가 왜 처음부터 이 내용을 가지고 왔을까?
일단 리스코프 치환 원칙의 정의는 이렇다.
💡
하위 타입의 객체는 상위 타입 객체에서 가능한 행위를 수행할 수 있어야 한다.
 
간단하게 생각해서 타입스크립트의 경우
interface SuperType { first: number; } interface SubType extends SuperType { second: number; } const superType: SuperType = { first: 1 }; const subType: SubType = { first: 2, second: 3 }; superType.first; // 1 superType.second; // Property 'second' does not exist on type 'SuperType'. subType.first; // 2 subType.second; // 3
 
위의 상황이라고 생각하면 된다.
이 예시에서 상위 타입 객체로 이해할 수 있는 SuperType은 first을 접근하여 행위를 수행하고 있다.
당연히도 하위 타입의 객체로 이해할 수 있는 SubType은 SuperType이 가능한 행위인 first 접근은 물론, second 접근 까지도 가능하다.
 

그래서 이 리스코프 치환 원칙을 왜 가져왔느냐?

리스코프 원칙에서는 까다로운 조건들이 있는데, 이 중 변성 에 대한 개념 또한 포함하고 있다.
 

서브타입에서 함수 타입을 정의할 때

  • 자식에서 함수 파라미터의 반공변성
  • 자식에서 리턴값의 공변성
 

서브 타입에서 함수를 구현할 때

  • 자식에서 메서드는 부모 메서드에서 던진 에러의 서브타입을 제외하고 새로운 에러를 던지면 안 된다.
  • 자식에서 선행 조건은 강화될 수 없다
  • 자식에서 후행 조건은 약화될 수 없다
  • 자식에서 부모의 불변 조건은 반드시 유지되어야 한다
 
위 내용 중에서 변성과 관련된 공변성, 반공변성에 대해 알아보겠다.

공변성

💡
A ⊂ B → T<A> ⊂ T<B>
⇒ A가 B의 서브집합인 경우, T<A>도 T<B>의 서브집합이 된다는 것이다.
 
간단한 예시를 보자.
type A = string; type B = string | number; type T<U> = (arg: string) => U; const aFunction: T<A> = (arg) => 'string!'; const bFunction: T<B> = aFunction;
A는 string ,B는 string | number 의 타입을 가지고 있다.
현재 상황에서 A ⊂ B 인 것이다.
 
이 상황에서는 뭔가 당연하게도 T<B>의 리턴 타입이 T<A>의 리턴 타입을 포함하고 있기 때문에 에러가 나지 않을 것 같다.
맞다. 이 상황에서는 에러가 나지 않는다.
위에서 설명한 공변성이 들어맞는 상황인 것이다.
 
type A = string; type B = string | number; type T<U> = (arg: string) => U; const bFunction: T<B> = (arg) => 'string!'; const aFunction: T<A> = bFunction; // 'string | number' 형식은 'string' 형식에 할당할 수 없습니다.
반대의 경우는 당연하게도 에러가 난다.
왜? string | number 의 범위가 더 크니까.
 

그렇다면 매개변수 타입은?

이번에는 매개변수에 적용을 시켜보자.
type A = string; type B = string | number; type T<U> = (arg: U) => string; const aFunction: T<A> = (arg) => 'string!'; const bFunction: T<B> = aFunction; // 'string | number' 형식은 'string' 형식에 할당할 수 없습니다.
string은 분명 string | number 의 서브타입임에도 불구하고 이상하게 에러가 나고 있다.
 
type A = string; type B = string | number; type T<U> = (arg: U) => string; const bFunction: T<B> = (arg) => 'string!'; const aFunction: T<A> = bFunction;
오히려 이 상황에서는 반대로 잘 작동하는 것을 볼 수 있다.
 
본인이 공변성, 반공변성에 대하여 설명하기 이전에 리스코프 치환 원칙을 설명한 것이 바로 그 이유이다.
 
자식에서 함수 파라미터의 반공변성
리스코프 치환 원칙에서 설명한 내용 처럼 타입스크립트에서 파라미터의 경우 반공변성을 띄고 있는 것을 알 수 있다.
 

반공변성

💡
A ⊂ B → T<B> ⊂ T<A>
⇒ A가 B의 서브집합인 경우, T<B>가 T<A>의 서브집합이 된다
 
위의 예시에서 봤던 내용처럼, 파라미터의 경우 공변성이 아닌 반공변성을 띈다는 것을 확인할 수 있다.
사실 조금만 생각해보면 이게 맞는 흐름으로 이해가 된다.
매개변수 타입을 설명하고 있는 위의 상황에서 aFunction 내부에서 파라미터가 number일 때의 실행문이 존재한다고 생각하면 너무나 당연하다.
 
그렇다면 타입스크립트의 변성은 여기까지만 보면 될까?
또 그렇지도 않다.
 

이변성

💡
A ⊂ B → T<A> ⊂ T<B> | T<B> ⊂ T<A>
⇒ A가 B의 서브집합인 경우, T<A>가 T<B>의 서브집합 이면서 T<B>가 T<A>의 서브집합이 된다
 
사실 타입스크립트는 함수의 인자를 다룰 때 이변성을 가지고 있다고 한다. ( 도대체 이게 무슨 소린가 했다 )
 
타입스크립트에는 —strictFunctionTypes 라는 옵션이 있다고 하는데, 저 반공변성을 테스트 할 때는 이 옵션이 true로 되어있어 반공변성이 적용된 것이다.
이 옵션을 off로 하게 되면 매개변수든 리턴값이든 마음대로 해도 상관이 없다는 거다. ( 가지가지 한다. )
 

References