📢 어렵고 정석적인 개념 설명보다는 저같은 초보자도 이해하기 쉽게 정리하는 것을 원칙으로 포스팅하고 있습니다. 😄

[TypeScript] 삼항 연산자를 이용한 타입스크립트 조건부 타입

조건부 타입이란?

type A = number extends string ? string : number;
// number extends string이 참이면 string, 거짓이면 number

type ObjA = {a: number};
type ObjB = {a: number; b: number};
type B = ObjB extends ObjA ? number : string;
// ObjB가 ObjA의 서브 타입이므로 true => number 타입

타입스크립트에서 삼항 연산자를 이용해 타입을 정하는 것을 조건부 타입이라고 한다. 주로 조건식으로 A extends B를 사용하며 A가 B의 서브 타입이면 true, 아니면 false를 반환한다.

 

위의 예시 코드를 보면 타입 A의 조건식은 number extends string이라고 되어 있다. number 타입은 string 타입의 서브 타입이 아니기 때문에 false를 반환하여 타입 A는 number 타입이 된다. 객체의 경우도 똑같다. 타입 B는 ObjB가 ObjA의 서브 타입이므로 true를 반환하여 number 타입이 된다.

 

제네릭에서 조건부 타입 사용하기

type StringNumber<T> = T extends number ? string : number;
let varA: StringNumber<number>; // string

// Ver1 : result의 타입이 string | undefined가 된다.
function removeSpace<T>(text: T) {
    if (typeof text === 'string') {
        return text.replaceAll(" ", "");
    } else {
        return undefined;
    }
}

// Ver2 : 조건식 부분이 함수 선언 부분에서 아직 어떤 타입인지를 모름
// 여기서 T의 타입은 T extends string ? string : undefined임
// 호출 받았을 때 T의 타입이 정해지는 것이지 선언할 때는 타입이 뭔지 모름
function removeSpace<T>(text: T): T extends string ? string : undefined {
    if (typeof text === 'string') {
        return text.replaceAll(" ", "");
    } else {
        return undefined;
    }
}

// Ver3 : 함수 오버로딩 사용
function removeSpace<T>(text: T): T extends string ? string : undefined;
function removeSpace(text: any) {
    if (typeof text === 'string') {
        return text.replaceAll(" ", "");
    } else {
        return undefined;
    }
}

let result = removeSpace('Hello World');

removeSpace()라는 함수가 있고, 이 함수는 제네릭에 조건부 타입을 사용한 타입을 값으로 반환한다.

일단, 조건부 타입 없이 제네릭만을 이용해서 매개변수에 타입 변수 T를 선언해 주었다. 이러면 결괏값의 타입이 자동으로 추론되므로 변수 result의 타입은 string | undefined 유니온 타입이 된다.

 

'Hello World'라는 명확한 string 타입이 있는데 undefined와 함께 유니온 타입으로 나온 게 마음에 들지 않는다.

그래서 결괏값 타입에 조건부 타입을 넣어 string의 서브 타입일 경우 string 타입, 아닐 경우 undefined가 나오게 해 주었다.

원한대로 result의 타입이 string 하나만 나오게 됐지만, 함수 선언 부분에서 오류가 발생했다. 선언 부분에서는 호출된 값의 타입을 모르기 때문에 T extends string가 참인지 거짓인지 모르기 때문이다.

 

어차피 호출되는 부분에서 string 타입이라고 잘 나오기 때문에 선언 부분에서 리턴값을 그냥 any 타입으로 선언해 줘도 된다. any 타입으로 선언하는 방법에는 타입 단언도 있겠지만, 우리는 함수 오버로딩을 사용하려고 한다.

조건부 타입의 리턴값을 함수 오버로딩으로 두고 매개변수 타입을 any로 주었다. 그럼 이제 함수 입장에서는 매개변수가 any 타입이기 때문에 리턴값 오류도 안 생기고 함수가 호출될 때도 조건부 타입에 의해서 유니온 타입이 아닌 string 타입으로 나올 수 있게 된다.

 

분산적인 조건부 타입

type StringNumber<T> = T extends number ? string : number;
let A: StringNumber<number | string>; // string | number;
// number extends number => string
// string extends number => number
// A의 타입은 string | number 유니온 타입

type Exclude<T, U> = T extends U ? never : T;
let B: Exclude<number | string | boolean, string>;
// number extends string => number
// string extends string => never
// boolean extends string => boolean
// B의 타입은 number | never | boolean
// never는 공집합이므로 number | boolean

분산적인 조건부 타입이란 제네릭 타입 변수에 유니온 타입이 들어오는 경우 유니온 타입이 통째로 타입 변수에 들어가는 것이 아니라 유니온 타입을 이루는 타입 하나씩 따로따로 타입 변수에 들어가는 것을 말한다.

 

위의 예시 코드를 보면 StringNumber 타입의 타입 변수 T에 number | string 유니온 타입이 들어갔다.

조건부 타입이 유니온 타입을 만나면 분산적인 조건부 타입으로 변하므로 number | string 유니온 타입은 쪼개져서 number 따로, string 따로 한 번씩 타입 변수 자리에 들어간다. 따라서, 결과 타입 또한 유니온 타입으로 나오게 된다. (중복되거나 never 타입이 없을 경우)

type StringNumber<T, U> = [T] extends [U] ? never : T;
type B = StringNumber<number | boolean, string>;
// number | boolean 유니온 타입은 string 타입의 서브 타입이 아니므로 타입 B는 number | boolean

만약, 제네릭과 유니온 타입이 만났을 때 분산적인 조건부 타입을 쓰고 싶지 않다면 어떻게 하면 될까?

바로 조건부 타입의 조건식의 타입 변수에 대괄호를 씌우면 된다. 위의 예시 코드에서 보면 number | boolean은 number와 boolean으로 따로따로 들어간 게 아닌 유니온 타입 통째로 T에 들어간 것을 볼 수 있다.

 

조건부 타입의 타입 추론 infer

type FuncA = () => string;
type FuncB = () => number;
type ReturnType<T> = T extends () => infer R ? R : never;

type A = ReturnType<FuncA>;
// T가 () => string
// 조건식이 참이 될 수 있도록 R 타입을 추론함
// 추론 결과 R이 string이 참이 나옴
// 따라서 R은 string

type B = ReturnType<number>;
// T가 number
// 조건식이 참이 될 수 있도록 R 타입을 추론함
// number와 () => infer R은 어떻게 해도 참이 될 수 없으므로 never

조건식으로 A extends infer B가 있을 때, B는 해당 조건식이 참이 되는 타입으로 추론된다.

하지만, 무조건 B가 타입으로 추론되는 것은 아니다. ReturnType<number>의 경우처럼 어떻게 해도 해당 조건식이 참이 될 수 없는 경우라면 B는 추론을 포기하고 조건부 타입의 false 타입을 반환한다.

 

1. A extends B ? number : string // 조건부 타입 (주로 A 타입이 B 타입의 서브 타입이냐가 조건식)
2. 제네릭과 함께 조건부 타입을 사용할 경우 유니온 타입이 타입 변수로 들어가면 분산적인 조건부 타입이 된다.
3. 분산적인 조건부 타입이란, 유니온 타입 통째로 들어가는 게 아니라 타입 하나씩 타입 변수에 들어가는 것을 말한다.
4. infer R은 조건부 타입의 조건식에서 쓰이며, 해당 조건식이 참이 되는 타입으로 추론된다.