...
고급 타입 - Conditional Types
조건부 타입(conditional type)이란 입력된 제네릭 타입에 따라 타입을 결정 기능을 말한다.
위와 같이 조건부 타입 문법은 extends 키워드와 물음표 ? 기호를 사용하는데, 보자마자 삼항 연산자가 생각 났을 것이다.
여러분이 유추한 바와 같이, 자바스크립트의 삼항 연산자는 변수의 값을 조건에 따라 결정하는 것이라면, 타입스크립트의 조건부 타입은 값 대신 타입을 조건에 따라 결정하는 것이라고 보면 된다.
위의 조건부 타입 코드 문법을 풀이해보자면, 타입은 T가 U에 할당될 수 있으면 타입은 X가 되고 그렇지 않다면 타입이 Y가 된다는 것을 의미한다.
착각하지 말아야 할점은 조건부 타입도 유니온 처럼 하나의 타입이라는 것이다.
extends 키워드가 들어가서 제네릭 꺾쇠 괄호 <> 안에 써야 하는줄 아는데, 그냥 별개의 타입 문법으로 취급된다고 보면 된다.
제네릭 extends 와 조건부 타입 extends는 역할만 같은 서로 다른 사용처 연산자라고 치부하는게 이해하기 좋다.
이를 실전 코드로 써보면 다음과 같이 될 수 있다.
// T extends U ? X : Y
// 제네릭이 string이면 문자열배열, 아니면 넘버배열
type IsStringType<T> = T extends string ? string[] : number[];
type T1 = IsStringType<string>; // type T1 = string[]
type T2 = IsStringType<number>; // type T2 = number[]
const a: T1 = ['홍길동', '임꺾정', '박혁거세'];
const b: T2 = [1000, 2000, 3000];
// 제네릭 `T`는 `boolean` 타입으로 제한.
// 제네릭 T에 true가 들어오면 string 타입으로, false가 들어오면 number 타입으로 data 속성을 타입 지정
interface isDataString<T extends boolean> {
data: T extends true ? string : number;
isString: T;
}
const str: isDataString<true> = {
data: '홍길동', // String
isString: true,
};
const num: isDataString<false> = {
data: 9999, // Number
isString: false,
};
단, 조건부 타입을 중첩해서 쓰면 다음과 같이 가독성이 매우 안좋아지니 감안해야 된다.
우리가 변수를 조건문을 통해 유기적으로 다룰수 있는 것처럼, 타입스크립트에서 타입도 유기적으로 다루기 위해, if문을 쓸수는 없으니 이런식으로 삼항 연산자를 중첩해서 써야하는 한계가 있다.
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends Function ? "function" :
"object";
type T0 = TypeName<string>; // "string"
type T1 = TypeName<"a">; // "string"
type T2 = TypeName<true>; // "boolean"
type T3 = TypeName<() => void>; // "function"
type T4 = TypeName<string[]>; // "object"
분산 조건부 타입
여기까지 보면 삼항 연산자 원리 정도는 프로그래밍 왕초보 딱지 땟을 때 이미 익혀둔 것이라, 간단히 넘어가면 되겠지 라고 생각하겠지만, 진짜 지옥은 이제 부터 이다.
다음 조건부 타입 코드를 보자.
이번에는 유니온 타입을 제네릭에 할당했다. 결과가 어떻게 나올까?
type IsStringType<T> = T extends string ? 'yes' : 'no';
type T1 = IsStringType<string | number>;
string | number 는 string 또는 number 이니 당연히 string에 포함되니 삼항 연산자의 결과는 'yes'가 되어 type T1의 타입은 'yes'가 됨을 유추 할 수 있다.
그러나 놀랍게도 타입은 'yes' | 'no' 로 추론 된다.
이것이 분산 조건부 타입(distributive conditional types)이다.
분산 조건부 타입은 타입을 인스터화 중에 자동으로 유니언 타입으로 분산되는데,
예를들어, T에 대한 타입 인수 A | B | C 를 사용하여 T extends U ? X : Y 를 인스턴스화하면 (A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y) 로 결정되게 된다.
한마디로 유니온으로 묶인 타입 하나하나 마다 조건부 타입 검사를 하고 그 결과값들을 묶어 다시 유니온으로 반환하는 것이다.
따라서 위의 타입 결과를 풀이해보자면 다음과 같이 된다.
(string | number) extends string ? 'yes' : 'no'(string extends string ? 'yes' : 'no')|(number extends string ? 'yes' : 'no')'yes' | 'no'
한가지 더 마술을 부려보겠다.
다음 두 예제 코드의 결과 타입을 추론해보자.
type T3 = string | number extends string ? 'yes' : 'no';
type T4 = Array<string | number>;
깨질듯한 머리를 겨우 안정화 하였는데 다시 박살 날 것 같다.
이번엔 대체 무슨 원리에 의해 저렇게 반대로 되는 것일까?
type T3 같은 경우는 그냥 제네릭만 안썼을 뿐이지 처음 예제와 똑같은 코드이다. 제네릭을 빼고 T 자리에 유니온 타입을 직접 넣었을 뿐인데 왜 결과가 딴판이 된 것일까?
이것 역시 분산 조건부 타입(distributive conditional types) 의 특징이다.
조건부 타입(conditional types) 에서 (naked) type parameter 가 사용된 경우에만 분산(distributive) 방식으로 동작하게 된다.
(naked) type parameter는 제네릭 T 와 같이 의미가 없는 타입 파라미터를 말하는 것이며,
만일 직접 리터럴 타입을 명시하거나 혹은 제네릭 T[] 와 같이 변횐된 타입 파라미터이면, naked 가 아니게 된다.
따라서 처음의 분산 조건부 타입 예제는 제네릭 T를 써서 그대로 분산이 되어 유니온 타입으로 타입 결과가 반환 됬지만, 유니온 타입을 제네릭이 아니라 직접 리터럴로 넣게되면, 분산이 일어나지 않았기 때문에 위의 결과가 나온 것이다.
이 특징을 총정리 하자면 다음 코드 예제가 될 수가 있다.
type T1 = (1 | 3 | 5 | 7) extends number ? 'yes' : 'no'; // naked 타입이 아니라서 분산이 되지 않는다.
type T2<T> = T extends number ? T[] : 'no'; // 제네릭 T는 naked 타입이라 분산이 된다.
type T3<T> = T[] extends number ? 'yes' : T[]; // 제네릭이지만 T[] 와 같이 변형된 타입 파라미터는 naked 타입이 아니라서 분산이 일어나지 않는다.
type T4 = T1; // "yes"
type T5 = T2<(1 | 3 | 5 | 7)>; // 1[] | 3[] | 5[] | 7[]
type T6 = T2<(1 | 3 | 5 | 7)>; // (1 | 3 | 5 | 7)[]
그리고 두번째의 경우 type T4 = Array<string | number> 어렵게 생각할 필요없이, 인터페이스 Array<T> 는 타입스크립트에서 기본으로 지원되는 제네릭 인터페이스로서 당연히 여기에는 조건부 타입(삼항 연산자)이 사용되지 않아 당연히 반환 값은 유니온 배열 (string | number)[] 이 되게 된다.
분산 조건부 타입에서의 never
분산 조건부의 분산 원리에는 또 한가지의 특별한 장치가 있는데 never 타입으로 분산이 됬을 경우 이 타입은 제외 시킨다는 특징이 있다.
예를 들어 다음과 같이 number | string | object 를 분산 조건부 타입 제네릭에 준다고 했을때, 결과는 number | never | never 가 되는 줄 알겠지만, 분산 조건부 타입에서의 never는 제외를 의미하기에 그냥 타입을 없애버려 number 만 반환되게 된다.
type Never<T> = T extends number ? T : never;
type Types = number | string | object;
type T2 = Never<Types>; // type T2 = number
(number extends number ? T : never) | (string extends number ? T : never) | (object extends number ? T : never)number | never | nevernumber
이를 이용해 다음과 같이,
두 타입 인자를 받아 해당하는 타입을 제외시키는 Exclude 조건부 타입과
두 타입 인자를 받아 해당하는 타입만 모아 반환 시키는 Extract 조건부 타입을 다음과 같이 구현 할 수 있다.
둘의 차이점은 never 위치가 앞뒤인 점 밖에 없다.
// 유니온 타입을 받아 T와 U를 비교해 U와 겹치는 타입들은 제외한 T를 반환하는 타입
type My_Exclude<T, U> = T extends U ? never : T;
type T2 = My_Exclude<(1 | 3 | 5 | 7), (1 | 5 | 9)>; // U 제네릭(1 | 5 | 9)에 속해있지 않은 3 | 7 만 반환됨
type T3 = My_Exclude<string | number | (() => void), Function>; // U 제네릭(Function)에 속해있지 않은 string | number 만 반환 됨
export {};
// 유니온 타입을 받아 T와 U를 비교해 U와 겹치는 타입들만 재구성해 T를 반환하는 타입
type My_Extract<T, U> = T extends U ? T : never;
type T4 = My_Extract<(1 | 3 | 5 | 7), (1 | 5 | 9)>; // U 제네릭에(1 | 5 | 9) 속해있는 1 | 5 만 반환됨
export {};
infer 키워드
T extends infer U ? X : Y
infer 키워드 원리는 타입 스크립트가 엔진이 런타임 상황에서 타입을 추론할 수 있도록 하고, 추론한 타입 값을 infer 타입 파라미터 U 에 할당해준다. 그리고 조건부 타입에 의해서 함수의 형태가 참이라면 파라미터를 아니라면 무시(never) 하는게 기본 동작이다.
위의 문법에서 볼수 있듯이, infer키워드는 조건부 타입에서 extends뒤에 사용되는 규칙을 갖고있다.
짧게 살펴보면, 제네릭 T 에 { a: string, b: string } 객체 타입이 들어가서 infer U 에 의해 string이 추론되어 참이 되어 자기 자신의 타입 U가 반환됨을 알수 있다.
type Foo<T> = T extends { a: infer U, b: infer U } ? U : never;
type T10 = Foo<{ a: string, b: string }>; // string
type T11 = Foo<{ a: string, b: number }>; // string | number
infer는 특히 함수를 추론하는데 있어 유용하게 사용되는데, 다음과 같이 함수의 인자 x 의 타입을 추론 infer U 하여 타입을 반환하는걸 알수 있다.
type Bar<T> = T extends { a: (x: infer U) => void, b: (x: infer U) => void } ? U : never;
type T20 = Bar<{ a: (x: string) => void, b: (x: string) => void }>; // string
type T21 = Bar<{ a: (x: string) => void, b: (x: number) => void }>; // string & number
위의 예제는 너무 이론적인 예제이고, 좀더 실용적인 예제를 다시 들어보겠다.
다음 fn 이라는 함수가 있다고 하자.
이 fn 함수는 매개변수로 number, string, boolean 로 구성된 타입을 가지고 있고, 리턴 타입은 string | void 유니온 타입이다.
infer 키워드를 사용하면 이 함수의 매개변수 타입과 리턱 타입을 뽑아 반환 할 수가 있다.
function fn(num: number, str: string, bool: boolean): string | void {
return num.toString();
}
type My_ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
type My_Parameters<T extends (...args: any) => any> = T extends (...args: infer R) => any ? R : never;
type Return_Type = My_ReturnType<typeof fn> // 함수의 리턴 타입을 반환
// type Return_Type = string | voi
type Parameters_Type = My_Parameters<typeof fn> // 함수의 파라미터들의 타입을 반환
// type Parameters_Type = [num: number, ste: string, bool: boolean]
const a: My_ReturnType<typeof fn> = 'Hello';
const b: My_Parameters<typeof fn> = [123, 'Hello', true];
우선 My_ReturnType<T> 가 어떤 원리로 함수의 리턴 타입만 뽑아 반환 할 수 있는지 알아보자.
꺾쇠 괄호 <T> 부분 제네릭 부분만 살펴본다면, <T extends (...args: any) => any> 이 뜻은 타입 파라미터 T는 오로지 함수 타입만 받을 수 있다는 말이다. 그래서 제네릭 인자에는 함수가 들어온다.
이를 위의 fn 함수의 타입에 빗대어보면 다음과 같이 된다.
미리 정의된 조건부 타입
TypeScript 2.8 버전 부터 lib.d.ts에 미리 정의된 조건부 타입(Predefined conditional types)을 추가됬다.
위에서 다루었던 조건부 타입을 응용해 유틸리티 타입 처럼, 미리 헬퍼(helper) 함수를 만들어 놓은 것으로 보면 된다.
미리 정의된 조건부 타입 종류는 다음과 같다.
Exclude<T, U>: U에 할당할 수 있는 타입은 T에서 제외.Extract<T, U>: U에 할당할 수 있는 타입을 T에서 추출NonNullable<T>: T에서 null과 undefined를 제외.ReturnType<T>: T가 함수일때, 함수 타입의 반환 타입을 얻기.InstanceType<T>: 생성자 함수 타입의 인스턴스 타입을 얻기.
type T00 = Exclude<"a" | "b" | "c" | "d", "a" | "c" | "f">; // "b" | "d"
type T01 = Extract<"a" | "b" | "c" | "d", "a" | "c" | "f">; // "a" | "c"
type T02 = Exclude<string | number | (() => void), Function>; // string | number
type T03 = Extract<string | number | (() => void), Function>; // () => void
type T04 = NonNullable<string | number | undefined>; // string | number
type T05 = NonNullable<(() => string) | string[] | null | undefined>; // (() => string) | string[]
function f1(s: string) {
return { a: 1, b: s };
}
class C {
x = 0;
y = 0;
}
type T10 = ReturnType<() => string>; // string
type T11 = ReturnType<(s: string) => void>; // void
type T12 = ReturnType<(<T>() => T)>; // {}
type T13 = ReturnType<(<T extends U, U extends number[]>() => T)>; // number[]
type T14 = ReturnType<typeof f1>; // { a: number, b: string }
type T15 = ReturnType<any>; // any
type T16 = ReturnType<never>; // never
type T17 = ReturnType<string>; // 오류
type T18 = ReturnType<Function>; // 오류
type T20 = InstanceType<typeof C>; // C
type T21 = InstanceType<any>; // any
type T22 = InstanceType<never>; // never
type T23 = InstanceType<string>; // 오류
type T24 = InstanceType<Function>; // 오류
# 참고자료
https://heropy.blog/2020/01/27/typescript/
https://typescript-kr.github.io/pages/advanced-types.html
제로초 타입스크립트 올인원
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.