공변성과 반공변성, 이변성

동기

타입스크립트에 관한 문서를 찾다보면 공변성(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의 인자의 타입을 포함하고 있는 슈퍼셋으로 생각할 수 있다.

그러면 당연히 AB 를 할당하는 것은 가능하지 않을까? 라고 생각했었다.

공변성반공변성에 대해 알아보면서 어느 부분에 논리적 오류가 있었는지 찾아보자.

공변성

타입 간 관계에서

BA를 포함하는 관계라면 AB의 서브타입이라고 한다.

const B: string | number
const A:: string

이라고 할 때에 AB의 서브타입이라고 할 수 있다.

앞으로 이 관계를 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을 받을 것으로 기대하기 때문에 Stringslice 메서드를 사용할 수 있었다.

함수 fnA에서의 인자가 slice 메서드를 사용할 수 있을까? 인자로 들어오는 some의 타입이 string임을 보장할 수 없기 때문에 ts에러가 발생한다.

Fn<B> → Fn<A>가 성립하지 않는다.

Fn<A> → Fn<B>의 관계는 성립할까?

Fn<A>string | number 타입을 받을 것으로 기대하기 때문에 string 타입만 받을 것으로 기대되는함수 fnBfnA를 할당할 수 있다.

따라서 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?

TypeScript: 공변과 반변, 그리고 객체 타입에서의 두 가지 함수 표기법 - Sorto.me

공변성이란 무엇인가 / seob.dev

좋은 웹페이지 즐겨찾기