
조건부 타입은 extends와 삼항 연산자를 이용해 조건에 따라 각각 다른 타입을 정의하도록 돕는 문법이다.
type A = number extends string ? number : string;
조건부 타입은 위 코드 처럼 number extends string ? 과 같은 조건식이 있고 이 조건이 참이라면 ? 우측의 타입인 number 타입이 결과가 되고 아니라면 : 우측의 타입인 string 타입이 결과가 된다.
위 조건부 타입의 조건식 number extends string은 number 타입이 string 타입의 서브타입이 아니기 때문에 거짓이 되고 그 결과 타입 A는 string 타입이 된다.
type ObjA = {
a: number;
};
type ObjB = {
a: number;
b: number;
};
type B = ObjB extends ObjA ? number : string;
B는 ObjB는 ObjA의 서브 타입 이므로 조건식이 참이되어 number 타입이 된다.
결론적으로 조건부 타입은 앞에 있는 타입이 뒤에 있는 타입의 서브타입인지 확인하여 삼항연산자를 실행하는 문법이다.
이러한 조건부타입은 기본타입보다는 제네릭과 함께 많이 쓴다.
아래는 타입변수에 number 타입이 할당되면 string 타입을 반환하고 그렇지 않다면 number 타입을 반환하는 조건부 타입이다.
type StringNumberSwitch<T> = T extends number ? string : number;
let varA: StringNumberSwitch<number>;
// string
let varB: StringNumberSwitch<string>;
// number
number 타입을 할당한다. 그 결과 조건식이 참이 되어 string 타입이 된다.string 타입을 할당한다. 그 결과 조건식이 거짓이 되어 number 타입이 된다.예제로 제네릭과 조건부 함수를 이해해보면,
매개변수로 string 타입의 값을 제공받아 공백을 제거한 다음 반환하는 함수가 있다고 하면,
function removeSpaces(text: string) {
return text.replaceAll(" ", "");
}
let result = removeSpaces("hi im winterlood");
//"hiimwinterlood"
이때 이 removeSpaces 함수의 매개변수에 undefined이나 null 타입의 값들도 들어갈 수 있다고 하면 매개변수의 타입을 다음과 같이 수정할 수 있다.
function removeSpaces(text: string | undefined | null) {
return text.replaceAll(" ", ""); // ❌ text가 string이 아닐 수 있음
}
let result = removeSpaces("hi im winterlood");
이때 함수 내부에서 text의 타입은 string이 아닐 수 있기 때문에 오류가 발생한다.
따라서 이런 경우 다음과 같이 타입을 좁혀 사용해야 한다.
function removeSpaces(text: string | undefined | null) {
if (typeof text === "string") {
return text.replaceAll(" ", "");
} else {
return undefined;
}
}
let result = removeSpaces("hi im winterlood");
// string | undefined
그러면 변수 result의 타입이 아까와는 달리 string | undefined 타입으로 추론된다.
이럴 때에는 조건부 타입을 이용해 인수로 전달된 값의 타입이 string이면 반환값 타입도 string이고 아니라면 반환값 타입을 undefined 으로 만들어 줘야한다.
function removeSpaces<T>(text: T): T extends string ? string : undefined {
if (typeof text === "string") {
return text.replaceAll(" ", ""); // ❌ 오류 발생
} else {
return undefined; // ❌ 오류 발생
}
}
let result = removeSpaces("hi im winterlood");
// string
let result2 = removeSpaces(undefined);
// undefined
타입변수 T를 추가하고 매개변수의 타입을 T로 정의한 다음 반환값의 타입을 T extends string ? string : undefined 으로 수정한다.
이제 변수 result 처럼 인수로 string 타입의 값을 전달하면 조건부 타입에 따라 반환값의 타입이 string이 되고, result2 처럼 인수로 undefined을 전달하면 반환값의 타입이 undefined이 된다.
그런데 이렇게 수정하니 2개의 return문 모두 오류가 발생하게 되는데, 이건 조건부 타입의 결과를 함수 내부에서 알 수 없기 때문이다.
따라서 다음과 같이 타입 단언을 이용해 반환값의 타입을 any 타입으로 단언해야한다.
function removeSpaces<T>(text: T): T extends string ? string : undefined {
if (typeof text === "string") {
return text.replaceAll(" ", "") as any;
} else {
return undefined as any;
}
}
let result = removeSpaces("hi im winterlood");
// string
let result2 = removeSpaces(undefined);
// undefined
하지만 any를 쓰는건 좋지않고, 여기서 string이 아닌 값을 return 하여도 타입스크립트는 체크하지 못한다.
따라서 이럴 때에는 타입 단언보다는 함수 오버로딩을 이용하는게 더 좋다.
오버로드 시그니쳐는 외부에서 호출할 때의 규칙을 정의하고, 구현 시그니쳐는 내부 로직을 처리하는 범용적인 함수로 분리되기 때문이다. 구현부에서는 unknown이나 any로 넓게 타입을 받아 유연하게 처리하고, 호출자에게는 조건부 타입이 적용된 정확한 타입을 반환한다고 약속하는 방식이다.
다음과 같이 오버로드 시그니쳐를 추가해 함수 오버로딩을 구현할 수 있다.
function removeSpaces<T>(text: T): T extends string ? string : undefined;
function removeSpaces(text: any) {
if (typeof text === "string") {
return text.replaceAll(" ", "");
} else {
return undefined;
}
}
let result = removeSpaces("hi im winterlood");
// string
let result2 = removeSpaces(undefined);
// undefined
여기서 text:any 조차 쓰기 싫다면 unknown 으로 사용하여 타입 체크를 강제하여 안전하게 쓸 수 있다. (이미 removeSpaces 에서는 타입체크가 되고 있다.)
// 선언부 (사용자가 보는 곳)
function removeSpaces<T>(text: T): T extends string ? string : undefined;
// 구현부 (unknown 사용)
function removeSpaces(text: unknown): string | undefined {
if (typeof text === "string") {
return text.replaceAll(" ", ""); // 여기서 string으로 좁혀짐
} else {
return undefined;
}
}
type StringNumberSwitch<T> = T extends number ? string : number;
let a: StringNumberSwitch<number>;
let b: StringNumberSwitch<string>;
변수 a의 타입은 조건식이 참이되어 string으로 정의되고 변수 b의 타입은 조건식이 거짓이 되어 number 타입으로 정의된다.
그럼 이번에는 타입 변수에 Union 타입을 할당하면 아래와 같다.
type StringNumberSwitch<T> = T extends number ? string : number;
let c: StringNumberSwitch<number | string>;
// string | number
변수 c의 타입은 number | string은 number의 서브타입이 아니므로 조건식이 거짓이 되어 number가 될거라고 예상할 수 있다.
그러나 변수 c는 string | number 타입으로 정의된다.
그 이유는 조건부 타입의 타입 변수에 Union 타입을 할당하면 분산적인 조건부 타입으로 조건부 타입이 업그레이드 되기 때문인데, 분산적인 조건부 타입은 다음과 같이 동작한다.
타입 변수에 할당한 Union 타입 내부의 모든 타입이 분리된다. 따라서 StringNumberSwitch<number | string> 타입은 다음과 같이 분산된다.
StringNumberSwitch<number> ⇒ stringStringNumberSwitch<string> ⇒ number그리고 다음으로 분산된 각 타입의 결과를 모아 다시 Union 타입으로 묶는다.
number | string그래서 만약 number를 넣지않고 Union 타입을 넣으면 number로 나오게 된다.
let d: StringNumberSwitch<boolean | string>; // number
type Exclude<T, U> = T extends U ? never : T;
type A = Exclude<number | string | boolean, string>;
위 코드는 다음의 흐름으로 동작한다.
Exclude<number, string>Exclude<string, string>Exclude<boolean, string>T = number, U = string 일 때 number extends string 은 거짓이므로 결과는 numberT = string, U = string 일 때 string extends string 은 참이므로 결과는 neverT = boolean, U = string 일 때 boolean extends string 은 거짓이므로 결과는 booleannumber | never | boolean계산 결과 타입 A는 number | never | boolean 타입으로 정의된다. 그런데 여기서 공집합을 의미하는 never 타입은 Union으로 묶일 경우 사라진다. 그 이유는 공집합과 어떤 집합의 합집합은 그냥 원본 집합이 되기 때문이다.
따라서 최종적으로 타입 A는 number | boolean 타입이 된다.
만약 반대로 교집합인 경우로 타입을 추론하고 싶으면 부정일때 never를 통해 교집합을 추출할 수 있다.
type Extract<T, U> = T extends U ? T : never;
type B = Extract<number | string | boolean, string>;
그리고 분산적인 조건을 막고 싶고, Union 그 자체로 비교를 하고 싶으면 [] 대괄호를 활용할 수 있다.
type StringNumberSwitch<T> = [T] extends [number] ? string : number;
let c: StringNumberSwitch<number | string>;
비교대상에 대괄호로 감싸게 되면, 유니온을 쪼개지 않고 "이 유니온 전체가 이 타입에 부합하나?"를 확인할 수 있다.
inference → 추론하다
infer는 조건부 타입 내에서 특정 타입을 추론하는 문법이다.
infer는 다음과 같이 특정 함수 타입에서 반환값의 타입만 추출하는 특수한 조건부 타입인 ReturnType을 만들 때 이용할 수 있다.
type ReturnType<T> = T extends () => infer R ? R : never;
type FuncA = () => string;
type FuncB = () => number;
type A = ReturnType<FuncA>;
// string
type B = ReturnType<FuncB>;
// number
조건식 T extends () => infer R에서 infer R은 이 조건식을 참이 되도록 만들 수 있는 최적의 R 타입을 추론하라는 의미이다.
따라서 A 타입을 계산할 때의 위 코드의 흐름은 다음과 같다.
FuncA가 할당된다.() ⇒ string extends () ⇒ infer R ? R : neverstring만약 다음과 같이 추론이 불가능하다면 조건식을 거짓으로 판단한다.
type ReturnType<T> = T extends () => infer R ? R : never;
type FuncA = () => string;
type FuncB = () => number;
type A = ReturnType<FuncA>;
// string
type B = ReturnType<FuncB>;
// number
type C = ReturnType<number>;
// 조건식을 만족하는 R추론 불가능
// never
Promise의 resolve 타입을 infer를 이용해 추출하는 예이다.
type PromiseUnpack<T> = T extends Promise<infer R> ? R : never;
// 1. T는 프로미스 타입이어야 한다.
// 2. 프로미스 타입의 결과값 타입을 반환해야 한다.
type PromiseA = PromiseUnpack<Promise<number>>;
// number
type PromiseB = PromiseUnpack<Promise<string>>;
// string
조건부 타입은 타입을 논리적으로 다룰 수 있게 해주는 TypeScript의 기능이며 유니온 타입과 결합될 때의 분산 법칙, 그리고 infer를 통한 타입 추출 기능을 잘 이해하면 타입스크립트를 보다 더 안전하고 스마트하게 쓸 수 있다.