icon
6장 : 제네릭 심화

조건부 타입과 제네릭


5장 2절에서 조건부 타입이 타입스크립트에서 타입에 조건부 로직을 적용하는 강력한 방법임을 배웠습니다. 이 조건부 타입은 제네릭(Generics) 과 결합될 때 그 진정한 위력을 발휘합니다. 제네릭 타입 변수를 기반으로 동적으로 타입을 결정하고 변형함으로써, 매우 유연하고 재사용 가능한 타입을 설계할 수 있습니다.

이 절에서는 조건부 타입이 제네릭과 어떻게 상호작용하는지, 그리고 이를 통해 어떤 고급 타입 조작이 가능한지 심도 있게 살펴보겠습니다.


제네릭 타입 변수를 활용한 조건부 타입

조건부 타입은 제네릭 타입 변수의 속성에 따라 다른 타입을 반환할 수 있습니다. 이는 특히 입력 타입에 따라 출력 타입이 달라지는 함수를 모델링할 때 유용합니다.

// IsNumber<T>는 T가 number에 할당 가능하면 T를, 아니면 string을 반환합니다.
type IsNumber<T> = T extends number ? T : string;

type Type1 = IsNumber<10>;       // Type1 = 10 (number 리터럴 타입)
type Type2 = IsNumber<number>;   // Type2 = number
type Type3 = IsNumber<string>;   // Type3 = string
type Type4 = IsNumber<boolean>;  // Type4 = string

// 만약 T가 number 속성을 포함하는 객체라면, 그 number 속성의 타입을 반환합니다.
// 그렇지 않다면 T 자체를 반환합니다.
type GetNumberProperty<T> = T extends { num: infer N } ? N : T;

type Type5 = GetNumberProperty<{ num: 123 }>;    // Type5 = 123
type Type6 = GetNumberProperty<{ num: number }>; // Type6 = number
type Type7 = GetNumberProperty<{ value: string }>; // Type7 = { value: string; }
type Type8 = GetNumberProperty<string[]>;       // Type8 = string[]

이처럼 제네릭 타입 변수 T의 형태나 속성에 따라 조건적으로 다른 타입을 반환함으로써, 입력 타입에 '반응'하는 타입을 만들 수 있습니다.


분산 조건부 타입과 제네릭의 시너지

5장 2절에서 다룬 분산 조건부 타입(Distributive Conditional Types) 은 제네릭과 함께 사용될 때 더욱 강력해집니다. 제네릭 타입 변수가 유니온 타입일 때, 조건부 타입은 유니온의 각 멤버에 개별적으로 적용된 후 다시 유니온으로 합쳐집니다.

이 특성은 특정 유니온 타입에서 원하는 타입만 필터링하거나 변형할 때 매우 유용합니다.

// Nullable<T>는 T가 null 또는 undefined이면 never를, 아니면 T를 반환합니다.
// 즉, T에서 null과 undefined를 제거합니다.
type NonNullAndUndefined<T> = T extends null | undefined ? never : T;

type MixedUnion = string | number | null | undefined | boolean;

// NonNullAndUndefined<MixedUnion>은 다음과 같이 분산되어 처리됩니다.
// (NonNullAndUndefined<string> | NonNullAndUndefined<number> | NonNullAndUndefined<null> | NonNullAndUndefined<undefined> | NonNullAndUndefined<boolean>)
// 결과: (string | number | never | never | boolean)
type CleanedUnion = NonNullAndUndefined<MixedUnion>;
// type CleanedUnion = string | number | boolean

이것이 바로 타입스크립트 내장 유틸리티 타입인 NonNullable<T>의 핵심 원리입니다. Exclude<T, U>Extract<T, U> 또한 이 분산 조건부 타입의 원리를 활용하여 구현됩니다.

// Exclude<T, U>의 모의 구현 (T에서 U에 할당 가능한 타입을 제외)
type MyExclude<T, U> = T extends U ? never : T;

type Statuses = "active" | "inactive" | "pending" | "deleted";
type CurrentStatuses = MyExclude<Statuses, "deleted">;
// type CurrentStatuses = "active" | "inactive" | "pending"
// Statuses의 각 멤버가 "deleted"에 할당 가능한지 검사합니다.
// ("active" extends "deleted" ? never : "active") => "active"
// ("inactive" extends "deleted" ? never : "inactive") => "inactive"
// ("pending" extends "deleted" ? never : "pending") => "pending"
// ("deleted" extends "deleted" ? never : "deleted") => never
// 결과: "active" | "inactive" | "pending" | never => "active" | "inactive" | "pending"

infer 키워드와 제네릭의 조합

5장 2절에서 다룬 infer 키워드는 조건부 타입 내에서 타입 변수를 추론하여 사용할 수 있게 해주며, 제네릭과 결합될 때 복잡한 타입의 일부를 동적으로 추출하는 데 매우 강력합니다.

// UnwrapPromise<T>는 Promise<U> 형태의 타입 T에서 U를 추출합니다.
// 만약 T가 Promise<any> 형태가 아니라면 T 자체를 반환합니다.
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type P1 = UnwrapPromise<Promise<string>>;       // P1 = string
type P2 = UnwrapPromise<Promise<Promise<number>>>; // P2 = Promise<number> (한 단계만 벗겨냄)
type P3 = UnwrapPromise<string>;                 // P3 = string

// DeepUnwrapPromise<T>는 재귀적으로 Promise를 벗겨내어 최종 값을 추출합니다.
type DeepUnwrapPromise<T> = T extends Promise<infer U> ? DeepUnwrapPromise<U> : T;

type DP1 = DeepUnwrapPromise<Promise<string>>;       // DP1 = string
type DP2 = DeepUnwrapPromise<Promise<Promise<number>>>; // DP2 = number
type DP3 = DeepUnwrapPromise<string>;                 // DP3 = string

interface UserResponse {
  id: number;
  name: string;
}

// GetPayload<T>는 T가 { payload: infer P } 형태를 가지면 P를 추출합니다.
type GetPayload<T> = T extends { payload: infer P } ? P : never;

type ApiSuccessResponse = { status: "success"; payload: UserResponse };
type ApiErrorResponse = { status: "error"; message: string };

type SuccessPayload = GetPayload<ApiSuccessResponse>; // SuccessPayload = UserResponse
type ErrorPayload = GetPayload<ApiErrorResponse>;     // ErrorPayload = never (payload 속성이 없으므로)

infer 키워드는 제네릭 타입 T의 내부 구조를 "들여다보고" 그 안에 있는 특정 타입 U를 뽑아내어 새로운 타입으로 사용할 수 있게 해줍니다. 이는 특히 함수 시그니처, 배열, 객체의 중첩된 타입을 다룰 때 매우 유용합니다.


조건부 타입과 제네릭의 고급 활용 예시

속성의 필수 여부에 따른 타입 분기

interface HasName {
  name: string;
}

interface HasId {
  id: number;
}

// IsRequired<T, K>는 T가 K 속성을 필수로 가지는지 여부에 따라 타입을 결정합니다.
type IsRequired<T, K extends keyof T> =
  undefined extends T[K] ? (null extends T[K] ? false : false) : true;

// 더 간단한 버전 (속성 자체가 optional인지 여부 판단)
type IsOptionalProperty<T, K extends keyof T> =
    undefined extends T[K] ? true : false;


type User = {
  id: number;
  name: string;
  email?: string; // 선택적 속성
}

type IdRequired = IsOptionalProperty<User, 'id'>;     // IdRequired = false (id는 optional이 아님)
type NameRequired = IsOptionalProperty<User, 'name'>; // NameRequired = false (name은 optional이 아님)
type EmailOptional = IsOptionalProperty<User, 'email'>; // EmailOptional = true (email은 optional임)

함수 오버로딩을 타입 레벨에서 구현

// TypeSafeFunctionOverload는 입력 타입에 따라 다른 반환 타입을 가지는 함수 시그니처를 정의합니다.
type TypeSafeFunctionOverload<T> = T extends number
  ? (x: T) => string
  : T extends string
  ? (x: T) => number
  : never;

const func1: TypeSafeFunctionOverload<number> = (x) => x.toString();
console.log(func1(123)); // "123"

const func2: TypeSafeFunctionOverload<string> = (x) => x.length;
console.log(func2("hello")); // 5

// const func3: TypeSafeFunctionOverload<boolean> = (x) => {}; // Error: 'boolean' 형식은 'number' 또는 'string' 형식에 할당할 수 없습니다.

조건부 타입과 제네릭의 조합은 타입스크립트의 타입 시스템을 매우 강력하고 유연하게 만들어줍니다. 이를 통해 개발자는 런타임 코드의 복잡성을 줄이면서도, 타입 안전성을 유지하며 다양한 시나리오에 대응할 수 있는 고도로 추상화된 타입을 정의할 수 있습니다. 특히 복잡한 라이브러리나 프레임워크를 개발할 때, 혹은 코드 베이스 전체의 타입 안정성을 높이고 싶을 때 이 기능들은 필수적으로 활용됩니다. 처음에는 어렵게 느껴질 수 있지만, 타입스크립트 내장 유틸리티 타입들의 구현을 분석해보면서 숙달하는 것이 좋은 학습 방법입니다.