타입스크립트에 관한 문서를 찾다보면 공변성(Covariance)
또는 반공변성(Contravariance)
이란 단어를 찾을 수 있다.
처음 타입스크립트를 접했을 때에는 크게 신경 쓰지 않고 지나갔었는데, 반복적으로 문서에 등장하다보니 한번 정리할 필요성을 느껴 조사하게 되었다.
가끔 작성한 코드의 타입이 당연히 동작할거라고 생각했을 때, 타입에서 오류가 잡힐 때가 있다. 당시에는 정확한 이유를 알지 못하고 any
로 타입을 바꾸어 사용했었는데 공변성에 대해 조사하면서 그 원인을 찾을 수 있었다.
다음과 같은 코드는 에러없이 동작할까?
let A: (A: string | number) => void;
let B: (A: string) => void = (A) => {
console.log('hi')
}
A = B;
A
는 인자의 타입으로 string
또는 number
를 받을 수 있다.
B
의 인자에는 string
만 받을 수 있으므로 A
의 인자의 타입은 B
의 인자의 타입을 포함하고 있는 슈퍼셋으로 생각할 수 있다.
그러면 당연히 A
에 B
를 할당하는 것은 가능하지 않을까? 라고 생각했었다.
공변성
과 반공변성
에 대해 알아보면서 어느 부분에 논리적 오류가 있었는지 찾아보자.
타입 간 관계에서
B
가 A
를 포함하는 관계라면 A
는 B
의 서브타입이라고 한다.
const B: string | number
const A:: string
이라고 할 때에 A
는 B
의 서브타입이라고 할 수 있다.
앞으로 이 관계를 A → B
로 표현한다.
A → B
가 성립할 때 Some<A> → Some<B>
의 관계가 성립하면 Some
을 공변적
이라고 부른다.
예시로 보내면 간단한 개념이다.
const ListB: Array<string | number> // Array<B>
const ListA: Array<string> // Array<A>
A → B
의 관계가 성립하고, ListA → ListB
의 관계도 역시 성립함을 볼 수 있다.
반공변성
은 이와 반대의 관계로 생각할 수 있다.
A → B
의 관계가 성립할 때, Some<B> → Some<A>
의 관계가 성립한다.
처음 떠올릴 때에 잘 와닿지 않는 개념이다.
아까 처음의 예제를 가지고 다시 생각해보자.
let A: (A: string | number) => void;
let B: (B: string) => void = (B) => {
console.log('hi')
}
A = B;
인자 간의 관계만 가지고 생각했을 때
B → A
의 관계가 성립한다. 함수타입 Fn
을 정의할 때에 Fn<B> → Fn<A>
의 관계도 성립할까?
위의 예시를 조금 더 자세히 정의하고 변형해보았다.
type CovarianceTester<T> = (param: T) => void;
type A = string | number;
type B = string;
let fnA: CovarianceTester<A> = (some) => {
console.log('A');
};
let fnB: CovarianceTester<B> = (some) => {
const slicedSome = some.slice(0,1);
console.log(slicedSome);
};
fnA = fnB //가능할까??
Fn<B> → Fn<A>
의 관계를 테스트해보기 위해 함수 타입 CovarianceTester
를 정의해주었다.
지금 관계에서는 B → A
이다.
이제 Fn<B> → Fn<A>
의 관계가 성립하는지 살펴보자.
함수 fnB
에서는 인자로 string
을 받을 것으로 기대하기 때문에 String
의 slice
메서드를 사용할 수 있었다.
함수 fnA
에서의 인자가 slice
메서드를 사용할 수 있을까? 인자로 들어오는 some
의 타입이 string
임을 보장할 수 없기 때문에 ts에러
가 발생한다.
Fn<B> → Fn<A>
가 성립하지 않는다.
Fn<A> → Fn<B>
의 관계는 성립할까?
Fn<A>
는 string | number
타입을 받을 것으로 기대하기 때문에 string
타입만 받을 것으로 기대되는함수 fnB
에 fnA
를 할당할 수 있다.
따라서 B -> A이면서 Fn<A> -> Fn<B>
의 관계가 성립한다고 할 수 있다.
이것이 반공변성
이다.
다음과 같은 간단한 상속관계를 생각해보자.
class Developer{
commit() {
console.log('git commit')
}
}
class WebDeveloper extends Developer{
httpRequest(){
console.log('http request')
}
}
class FrontendDeveloper extends WebDeveloper{
createReactApp(){
console.log('create React App')
}
}
FrontendDeveloper -> WebDeveloper -> Developer
의 상속관계를 가진다.
이 클래스들을 통해 이변성
이 무엇인지 확인해보자.
type ClassTester<T> = (param: T) => void;
let classMaker: ClassTester<WebDeveloper> = (Param) => {
Param.commit()
}
classMaker = (Param: Developer) => {
Param.commit()
}
classMaker = (Param: FrontendDeveloper) => {
Param.commit()
}
이전에 살펴본 바와 같이 FrontendDeveloper -> WebDeveloper -> Developer
의 상속관계에서
ClassTester<FrontendDeveloper> <- ClassTester<WebDeveloper> <- ClassTester<Developer>
의 관계를 가진다.
따라서
classMaker = (Param: FrontendDeveloper) => {
Param.commit()
}
마지막 할당과정에서 에러가 발생해야 한다.
그런데 --strict
옵션을 따로 켜두지 않았다면 에러가 발생하지 않았을 것이다.
이것이 타입스크립트에서의 이변성(Bivariance)
이다.
공변성
과 반공변성
을 동시에 지닌다는 의미이다.
이 함수의 경우
WebDeveloper -> Developer / Fn<Developer> -> Fn<WebDeveloper>
FrontendDeveloper -> WebDeveloper / Fn<FrontendDeveloper> -> Fn<WebDeveloper>
공변성
과 반공변성
을 모두 지니고 있다고 볼 수 있다.FrontendDeveloper
를 인자로 받는 함수가 WebDeveloper
를 인자로 받도록 설정된 함수에 할당된다면 아까 반공변성
파트에서 알아보았던 것처럼 Type Safe
하지 않을수도 있다. 그러면 타입스크립트에서는 왜 이러한 동작을 보일까??
함수 파라미터가 이변성을 가진 이유 에서 관련된 내용을 확인할 수 있다.
여기서 간단하게 요약하면 Array
와 메서드의 관계 등을 살펴볼 때 논리적 흐름을 보장하기 위해 함수임에도 공변성
을 보장해야하는 경우가 있기 때문에 이변성
을 선택했다고 한다.
추가설명 - Array<string> -> Array<string | number>
임이 자명하다. 반공변성
에 따르면 Array<string>.push -> Array<string | number>.push
가 성립해서는 안된다. 하지만 Array<string> -> Array<string | number>
이기 때문에 push
메서드를 포함
하여 할당이 가능해야 한다. 그래서 이변성이 필요하다!
마지막으로 타입스크립트에서 해당 이변성
을 어떻게 통제할 수 있는지 알아보자.
기본적으로 strict
또는 strictFunctionTypes
옵션을 켜서 함수가 반공변적
으로 동작하도록 강제할 수 있다.
위의 예시에서 해당 옵션을 킬 경우 에러가 발생한다!
하지만 strict
옵션을 키더라도 아까 약술한 내용대로 함수의 타입중에는 이변성
을 보장받아야 하는 것들이 존재한다. 타입스크립트는 어떻게 이변성
을 보장해 주었을까? 그 문법적 트릭
은 메서드 정의 방식
에 있다.
줄여쓰는 방식을 사용할 경우에는 이변적
으로 동작하고,
그렇지 않을 경우에는 반공변성
이 강제된다.
예시를 살펴보자.
interface Trick<T> {
makeTrick: (param: T) => void
}
//에러가 발생한다.
const TrickA: Trick<WebDeveloper> = {
makeTrick(testDeveloper: FrontendDeveloper){
}
}
interface Trick<T> {
makeTrick(param: T): void
}
//에러가 발생하지 않는다.
const TrickA: Trick<WebDeveloper> = {
makeTrick(testDeveloper: FrontendDeveloper){
}
}
일반적인 경우에 우리는 작성하는 코드가 Type Safe
하길 원한다. 따라서 줄여쓰는 방식을 선택하기 보다는 화살표 표기법을 이용하여 반공변성
을 강제하는 방식으로 사용해야 할 것이다.
What is the benefit of using '--strictFunctionTypes' in Typescript?