Frontend

타입스크립트 - Conditional Types

취업하고싶다! 2025. 4. 2. 15:56
반응형

 

[타입스크립트] 조건부 타입

조건부 타입(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,
};

 


분산 조건부 타입

type IsStringType<T> = T extends string ? 'yes' : 'no';

type T1 = IsStringType<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) 로 결정되게 된다.

한마디로 유니온으로 묶인 타입 하나하나 마다 조건부 타입 검사를 하고 그 결과값들을 묶어 다시 유니온으로 반환하는 것이다.

따라서 위의 타입 결과를 풀이해보자면 다음과 같이 된다.

 

  1. (string | number) extends string ? 'yes' : 'no'
  2. (string extends string ? 'yes' : 'no') | (number extends string ? 'yes' : 'no')
  3. '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
  1. (number extends number ? T : never) | (string extends number ? T : never) | (object extends number ? T : never)
  2. number | never | never
  3. number

이를 이용해 다음과 같이, 두 타입 인자를 받아 해당하는 타입을 제외시키는 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 | void

type Parameters_Type = My_Parameters<typeof fn> // 함수의 파라미터들의 타입을 반환
// type Parameters_Type = [num: number, str: 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 함수의 타입에 빗대어보면 다음과 같이 된다.

 

 

 

조금 더 쉽게 이해해보자. 

type My_ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

My_ReturnType 은 제네릭으로 (...args: any) => any 꼴의 함수 타입을 받고 있다. 그리고 반환하는 함수부를 해석해보면 다음과 같다.

"제네릭으로 받은 T가 (...args: any) => infer R 형태를 보이면 R 을 반환하고, 그렇지 않으면 any 를 반환하라."

여기서 infer R 은 ts 가 자체적으로 추론한 함수의 return type 을 의미한다.

const returnString = () =>{
	return 'hi';
}
// returnString은 () => string으로 추론됨

const returnNumber = () =>{
	return 1;
}
// returnNumber는 () => number로 추론됨

위 예시에서 ( ) => string 으로 추론되는 returnString 을 My_ReturnType 에 넘긴다면 (...args: any) => infer R 형태에 ( ) => string 이 대치되면서 R 이 string 타입을 갖게 된다.

같은 원리로, () => number 로 추론되는 returnNumber 를 My_ReturnType 에 너기면 (...args: any) => infer R 형태에 ( ) => number 이 대치되면서 R이 number 타입을 갖게 된다.

type returnType1 = My_ReturnType<typeof returnString>; //string
type returnType2 = My_ReturnType<typeof returnNubmer>; //number

 

 

Typescript의 infer 키워드는 제네릭 타입을 다룰 때 매우 유용한 도구이기에,
복잡한 타입 변환과 추론 작업을 간결하고 효과적으로 수행할 수 있다.

 


미리 정의된 조건부 타입 

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>;  // 오류
반응형

'Frontend' 카테고리의 다른 글

[Nginx] Nginx란?  (0) 2025.04.07
타입스크립트 - Mapped Types  (0) 2025.04.03
타입스크립트 - Utility Types  (0) 2025.04.02
도커(Docker)  (8) 2025.03.27
Submodule 구축  (0) 2025.03.11