Navigate back to the homepage

타입 가드 함수와 오버로드

younho9
August 28th, 2021 · 4 min read

변경사항

2021년 9월 5일 업데이트

  • 오버로드 함수가 인자로 사용될 때 추론이 정확하지 않은 문제 부분이 추가되었습니다.
  • 제네릭과 인터섹션 활용 방법이 추가되었습니다.

TL;DR

  • 중복되는 타입 가드 함수에 오버로드를 사용해보자.
  • 오버로드가 있는 함수는 타입 추론 시 적용 가능한 첫 번째 시그니쳐가 선택된다.
  • 오버로드 시그니쳐을 작성할 때는 순서가 중요하다. 구체적 타입 > 일반적 타입 순서로 작성한다.
  • 타입 가드 함수 자체를 인자로 사용할 때(ex. Array.prototype.filter) 컴파일러의 함수 추론에 문제가 있는 경우, 명시적으로 타입 인자를 넘겨 사용한다.
  • 오버로드를 사용하지 않고, 제네릭과 인터섹션 활용하는 방법으로 쉽게 해결할 수 있는 경우도 있다.

Intro

타입스크립트를 사용하다보면, 타입 가드 함수를 사용해서 타입의 범위를 좁혀야 하는 경우가 생긴다.

그런데, 타입 가드 함수를 하나 둘씩 추가하다 보니, 같은 기능의 함수가 중복되어 생성되는 경우가 있었다.

Example

1interface Coffee {
2 type: 'coffee';
3}
4
5interface CoffeeInfo extends Coffee {
6 price: number;
7 caffeine: number;
8}
9
10interface CoffeeDetailInfo extends CoffeeInfo {
11 coffeeBean: string;
12}
13
14interface Ade {
15 type: 'ade';
16}
17
18interface AdeInfo extends Ade {
19 price: number;
20 sugar: number;
21}
22
23interface AdeDetailInfo extends AdeInfo {
24 fruit: string;
25}
26
27type Drink = Coffee | Ade;
28type DrinkInfo = CoffeeInfo | AdeInfo;
29type DrinkDetailInfo = CoffeeDetailInfo | AdeDetailInfo;

Coffee 를 확장한 CoffeeInfoCoffeeInfo를 확장한 CoffeeDetailInfo 가 있고, Ade 를 확장한 AdeInfoAdeInfo를 확장한 AdeDetailInfo 가 있는 상황이다.

어떤 API에서 커피 또는 에이드의 정보를 묶어서 “음료 정보”로 전달하고, 어떤 API에서는 커피 또는 에이드의 상세 정보를 묶어서 “음료 상세 정보”로 전달한다.

“음료 정보” 중에서 “커피 정보”를 찾기 위한 타입 가드 함수와, “음료 상세 정보” 중에서 “커피 상세 정보”를 찾기 위한 타입 가드 함수를 다음과 같이 작성했다.

1function isCoffeeInfo(value: DrinkInfo): value is CoffeeInfo {
2 return value.type === 'coffee';
3}
4
5function isCoffeeDetailInfo(value: DrinkDetailInfo): value is CoffeeDetailInfo {
6 return value.type === 'coffee';
7}

이제 이 함수들을 사용해서 “커피 정보”와 “커피 상세 정보”를 안전하게 사용할 수 있게 되었다.

1function getCaffeine(value: DrinkInfo): number {
2 if (isCoffeeInfo(value)) {
3 // (parameter) value: CoffeeInfo
4 return value.caffeine;
5 }
6
7 return 0;
8}
9
10function getCoffeeBean(value: DrinkDetailInfo): string | null {
11 if (isCoffeeDetailInfo(value)) {
12 // (parameter) value: CoffeeDetailInfo
13 return value.coffeeBean;
14 }
15
16 return null;
17}

Playground Link

그런데 isCoffeeInfoisCoffeeDetailInfo는 함수 시그니쳐만 다를 뿐, 함수의 기능은 같다.

자바스크립트를 사용했다면, 굳이 작성하지 않아도 될 함수가 하나 늘어난 것 같다.

같은 기능의 함수인데, 하나로 줄일 순 없을까?

첫 번째 시도: 제네릭

타입스크립트에는 제네릭이라는 기능이 있다. 이를 활용하면 타입 변수를 사용할 수 있다.

1function isCoffee<U extends T, T extends Drink = Drink>(value: T): value is U {
2 return value.type === 'coffee';
3}
4
5function getCaffeine(value: DrinkInfo): number {
6 if (isCoffee<CoffeeInfo>(value)) {
7 // (parameter) value: CoffeeInfo
8 return value.caffeine;
9 }
10
11 return 0;
12}
13function getCoffeeBean(value: DrinkDetailInfo): string | null {
14 if (isCoffee<CoffeeDetailInfo>(value)) {
15 // (parameter) value: CoffeeDetailInfo
16 return value.coffeeBean;
17 }
18
19 return null;
20}

Playground Link

isCoffee라는 함수를 만들고, 두 번째 타입 매개변수인 TDrink를 확장해야 한다는 제약과 기본값으로 두고, 첫 번째 타입 매개변수인 UT를 확장해야 한다는 제약을 두어서, 첫 번째 타입 매개변수로 받은 타입 임을 가드하게 했다.

이렇게 하니, 하나의 함수로 “커피 정보”와 “커피 상세 정보”를 안전하게 사용할 수 있게 되었다.

하지만, 함수를 호출할 때마다, 명시적인 타입을 인수로 전달을 해줘야 하는 번거로움이 있다.

타입스크립트 컴파일러가 전달하는 인수에 따라 추론할 수 있게 작성하는 방법은 없을까?

두 번째 시도: 제네릭과 조건부 타입

타입스크립트에는 조건부 타입이라는 기능이 있다. 이를 활용하면 타입스크립트 컴파일러가 타입을 조건에 따라 추론하게끔 할 수 있다.

1type CoffeeOf<T> = T extends DrinkDetailInfo
2 ? CoffeeDetailInfo
3 : T extends DrinkInfo
4 ? CoffeeInfo
5 : T extends Drink
6 ? Coffee
7 : never;
8
9// Coffee
10type A = CoffeeOf<Drink>;
11// CoffeeInfo
12type B = CoffeeOf<DrinkInfo>;
13// CoffeeDetailInfo
14type C = CoffeeOf<DrinkDetailInfo>;

Playground Link

CoffeeOf<T>은 타입 매개변수로 받은 T가 “음료 상세 정보”면, “커피 상세 정보”, “음료 정보”면, “커피 정보”, “음료 기본”이면 “커피 기본”, 그리고 무엇도 아니면 never인, 요약하면 “커피 응답”을 찾아주는 타입이다.

조건부 타입을 작성할 때 주의사항은, 일반적인 삼항연산자처럼 조건에 부합하는 경우 즉시 리턴되므로, 좁은 범위의 타입부터 작성해야 정상적으로 타입을 추론할 수 있다는 것이다.

아래의 경우처럼, Drink부터 조건문을 작성할 경우, 아래의 모든 타입이 Drink를 확장하기 때문에, 모든 타입을 Coffee로 추론하게 된다.

1type CoffeeOf<T> = T extends Drink
2 ? Coffee
3 : T extends DrinkInfo
4 ? CoffeeInfo
5 : T extends DrinkDetailInfo
6 ? CoffeeDetailInfo
7 : never;
8
9// Coffee
10type A = CoffeeOf<Drink>;
11// Coffee
12type B = CoffeeOf<DrinkInfo>;
13// Coffee
14type C = CoffeeOf<DrinkDetailInfo>;

Playground Link

이를 사용해 인자의 조건에 따라 “커피 응답”을 찾을 수 있게 되었다.

그럼 이를 타입 가드 함수에 적용해볼 수 있을까?

“음료 기본 응답”을 확장하는 인자의 타입을 가지고, 인자의 조건에 맞는 “커피 응답”을 찾아서 인자가 조건에 부합하는 “커피 응답”임을 가드해주는 함수를 작성했다.

1function isCoffee<T extends Drink>(value: T): value is CoffeeOf<T> {
2 return value.type === 'coffee';
3}

Playground Link

하지만, 이 함수는 에러가 발생한다.

1// A type predicate's type must be assignable to its parameter's type.
2// Type 'CoffeeOf<T>' is not assignable to type 'T'.
3// 'CoffeeOf<T>' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Drink'.

이것은 제네릭 타입 매개변수에 제네릭 제약조건 타입을 할당할 수 없다는 에러로, 아래의 함수에서도 동일한 에러가 발생한다.

1// Type 'string' is not assignable to type 'T'.
2// 'string' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'string'.
3function fn1<T extends string>(x: T): T {
4 return 'hello world!';
5}

즉, 제네릭 타입 매개변수 T의 제약사항인 T extends stringTstring 타입 이다를 뜻하는 것이 아니라, Tstring 타입의 서브타입이다라는 뜻이고, 서브타입에 슈퍼타입을 할당할 수 없기 때문에 에러가 발생하는 것이다.

이 규칙은 타입 가드 함수의 술어 부분(predicate)에서도 적용되어, 추론하는 타입 역시 파라미터에 “할당 가능”해야 한다는 제약이 있다.

결국, 조건부 타입을 사용해서, 전달하는 인자의 타입으로 가드할 타입을 추론하게 끔하는 방법을 찾지 못했다.

그러면 항상 첫 번째 방법처럼, 명시적인 타입을 타입 인수로 전달해주면서 사용해야 할까?

세 번째 시도: 함수 오버로딩

다시 처음 문제의 원인을 생각해보자.

isCoffeeInfoisCoffeeDetailInfo는 함수 시그니쳐만 다를 뿐, 함수의 기능은 같다.

“함수 기능은 같은데 시그니쳐만 다르다”는 것이 문제의 원인이었다. 사실 이를 위한 타입스크립트의 기능은 함수 오버로드이다.

1function isCoffee(value: DrinkDetailInfo): value is CoffeeDetailInfo;
2function isCoffee(value: DrinkInfo): value is CoffeeInfo;
3function isCoffee(value: Drink): value is Coffee {
4 return value.type === 'coffee';
5}

오버로드 함수는 오버로드 시그니쳐와 구현 시그니쳐로 구성된다.

자바스크립트에서 오버로드는 인자의 조건(타입과 갯수)에 따른 분기를 함수 내부에 작성하는 방법이기 때문에, 타입스크립트에서도 오버로드 함수는 모든 오버로드 시그니쳐를 커버할 수 있는 하나의 구현 시그니쳐를 작성해야 한다.

구현 시그니쳐는 가장 아래에 위치하고, 나머지 오버로드 함수 시그니쳐와 호환되어야 한다.

따라서 isCoffee의 구현 시그니쳐에는 “음료 응답”의 가장 넓은 범위의 타입인 Drink를 인자의 타입으로 갖고, “커피 응답”의 가장 넓은 범위의 타입인 Coffee를 술어 부분의 추론 타입으로 작성했다.

그리고 나머지 두 오버로드 시그니쳐의 인자 타입인 DrinkInfoDrinkDetailInfoDrink에 호환되고 술어 부분의 추론 타입인 CoffeeInfoCoffeeDetailInfoCoffee에 호환된다.

1function getCaffeine(value: DrinkInfo): number {
2 if (isCoffee(value)) {
3 // (parameter) value: CoffeeInfo
4 return value.caffeine;
5 }
6
7 return 0;
8}
9
10function getCoffeeBean(value: DrinkDetailInfo): string | null {
11 if (isCoffee(value)) {
12 // (parameter) value: CoffeeDetailInfo
13 return value.coffeeBean;
14 }
15
16 return null;
17}

Playground Link

이제 하나의 함수 isCoffee 만으로, 타입 인자를 명시적으로 전달하지 않고 타입스크립트가 전달 받는 인자의 타입으로 추론할 수 있게 되었다.

오버로드 함수는 컴파일되면 하나의 함수만 남기 때문에, 기존에 같은 기능을 하는 함수가 여러 개 존재하는 것보다 번들사이즈를 줄일 수 있다.

그리고 같은 기능의 함수가 여러 개 있는 것보다 유지보수하기 쉬워진다.

함수 오버로딩에서의 유의사항

함수 오버로딩에서 오버로드 시그니쳐의 순서는 중요하다.

적용 가능한 첫 번째 오버로드 시그니쳐가 선택되기 때문에, 조건부 타입을 작성할 때 처럼, 보다 구체적인 서명을 먼저 배치해야 한다.

참고: Function Overloads > ordering

그리고, 오버로드가 있는 함수의 구현 시그니쳐는 .d.ts 파일에 생성되지 않는다.

2021-08-28-type-guard-and-overload-image-2
2021-08-28-type-guard-and-overload-image-3

마지막으로, Array.prototype.filter에서 사용될 때와 같이, 오버로드 함수 자체가 인자로 사용될 때 올바른 오버로드 시그니쳐를 추론하지 못하는 문제가 있었다.

1// 시그니쳐 (1)
2function isCoffee(value: DrinkDetailInfo): value is CoffeeDetailInfo;
3// 시그니쳐 (2)
4function isCoffee(value: DrinkInfo): value is CoffeeInfo;
5function isCoffee(value: Drink): value is Coffee {
6 return value.type === 'coffee';
7}
8
9declare const drinkInfos: DrinkInfo[];
10// 시그니쳐 (1)이 뒤에 올 때 에러가 발생함.
11drinkInfos.filter(isCoffee).map((value) => value.caffeine);
12
13declare const drinkDetailInfos: DrinkDetailInfo[];
14// 시그니쳐 (2)가 뒤에 올 때 에러가 발생함.
15drinkDetailInfos.filter(isCoffee).map((value) => value.coffeeBean);

시그니쳐 (1)시그니쳐 (2)의 순서가 바뀔 때마다, 에러의 위치가 달라진다.

이런 케이스처럼 아직 올바른 유형을 추론하기 위해 타입스크립트 컴파일러의 추론에 의존할 수 없는 경우가 있다. 이런 경우에는 Array.prototype.filter에 명시적으로 타입 인자를 넘겨주는 것으로 사용할 수 있다.

1declare const drinkInfos: DrinkInfo[];
2drinkInfos.filter<CoffeeInfo>(isCoffee).map((value) => value.caffeine);
3
4declare const drinkDetailInfos: DrinkDetailInfo[];
5drinkDetailInfos
6 .filter<CoffeeDetailInfo>(isCoffee)
7 .map((value) => value.coffeeBean);

Playground Link

오버로드 함수가 인자로 사용될 때 추론이 정확하지 않은 문제

위의 상황에 대해 타입스크립트에 이슈를 남기고 답변받았다.

답변에 따르면 타입스크립트는 오버로드 함수의 마지막 오버로드 시그니쳐에 대해서만 추론한다.

filter 함수의 타입은 다음과 같다.

1filter<S extends T>(predicate: (value: T, index: number, array: T[]) => value is S, thisArg?: any): S[];

따라서, 처음에는 마지막 오버로드 시그니쳐인 function isCoffee(value: DrinkInfo): value is CoffeeInfo;predicate의 타입으로 추론하지만, CoffeeInfoDrinkDetailInfo에 할당되지 않기 때문에, fallback으로서 원본 배열인 DrinkDetailInfoS의 타입으로 추론하게 된다.

1declare const drinkDetailInfos: DrinkDetailInfo[];
2// value: DrinkDetailInfo
3drinkDetailInfos.filter(isCoffee).map((value) => value.coffeeBean);

제네릭과 인터섹션 활용

또한, 현재 예시와 같은 서로소 집합 타입(discrimination union)일 때는, 굳이 오버로드를 사용하지 않고, 제네릭과 인터섹션을 활용하는 방법 있었다.

1function isCoffee<T extends Drink>(value: T): value is T & {type: 'coffee'} {
2 return value.type === 'coffee';
3}

이렇게 제네릭에 인터섹션을 추가하는 것으로, 가드된 타입은 { type: 'coffee' }를 갖고 있기 때문에 커피임을 구분할 수 있게 된다.

이 방법을 사용하면, 조건부 타입이나 오버로드 함수까지 사용할 필요 없이, 첫 번째 접근 방법에서 아주 쉽게 해결할 수 있게 된다.

Playground Link

참고자료

More articles from younho9

Git config으로 Git 조금 더 잘 쓰기

새로 사용하게 된 맥북 초기 설정을 하면서 Git 설정을 하게 되었고, 이 기회에 사용하고 있는 Git config 옵션에 대해 간단히 살펴보고 정리해두기로 했다.

March 5th, 2021 · 2 min read

Docusaurus로 문서 관리하기 - 2

이전 글에서는 Docusaurus를 간단히 알아보고, 설치 방법, 문서 설정 및 배포 방법을 알아봤다. 이번 글에서는 간단한 테마 커스터마이징 방법, Utterance 를 이용한 소셜 댓글 추가 방법, Algolia 문서 검색을 연결하는 방법 등을 다뤄보겠다.

February 24th, 2021 · 4 min read
© 2020–2021 younho9
Link to $https://github.com/younho9Link to $https://www.instagram.com/younho_9/