[Daily morning study] TypeScript 제네릭(Generics)과 조건부 타입(Conditional Types)

#daily morning study

Image


제네릭(Generics)이란

제네릭은 타입을 파라미터처럼 다룰 수 있게 해주는 기능이다. 특정 타입에 종속되지 않고 다양한 타입에 동작하는 재사용 가능한 컴포넌트를 만들 수 있다.

// 제네릭 없이 작성하면 타입이 고정됨
function identity(arg: number): number {
  return arg;
}

// any를 쓰면 타입 안전성을 잃음
function identity(arg: any): any {
  return arg;
}

// 제네릭을 쓰면 타입 안전성을 유지하면서 유연하게
function identity<T>(arg: T): T {
  return arg;
}

const result = identity<string>("hello"); // result: string
const result2 = identity(42);            // result2: number (타입 추론)

T는 관습적으로 사용하는 이름이지만, 어떤 식별자든 쓸 수 있다. K, V, U 같은 이름도 흔히 쓴다.


제네릭 함수

// 배열에서 첫 번째 원소를 반환하는 함수
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

first([1, 2, 3]);       // number | undefined
first(["a", "b", "c"]); // string | undefined

// 두 값을 쌍으로 묶는 함수
function pair<A, B>(a: A, b: B): [A, B] {
  return [a, b];
}

pair("key", 42); // [string, number]

제네릭 인터페이스와 타입 별칭

interface Box<T> {
  value: T;
  label: string;
}

const numberBox: Box<number> = { value: 42, label: "숫자 상자" };
const stringBox: Box<string> = { value: "hello", label: "문자 상자" };

// 타입 별칭에서도 동일하게 사용
type Nullable<T> = T | null;
type ApiResponse<T> = {
  data: T;
  status: number;
  message: string;
};

제네릭 제약(Constraints)

extends를 사용하면 제네릭 타입이 특정 조건을 만족해야 한다고 제약할 수 있다.

// T는 반드시 length 속성을 가져야 함
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

getLength("hello");    // OK
getLength([1, 2, 3]);  // OK
getLength(42);         // 오류: number에는 length가 없음

// keyof를 이용한 제약
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person = { name: "Alice", age: 30 };
getProperty(person, "name"); // string
getProperty(person, "age");  // number
getProperty(person, "email"); // 오류: 존재하지 않는 키

제네릭 클래스

class Stack<T> {
  private items: T[] = [];

  push(item: T): void {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }

  get size(): number {
    return this.items.length;
  }
}

const numStack = new Stack<number>();
numStack.push(1);
numStack.push(2);
numStack.pop(); // 2

const strStack = new Stack<string>();
strStack.push("a");

조건부 타입(Conditional Types)

조건부 타입은 타입 레벨에서 삼항 연산자처럼 작동한다.

type IsString<T> = T extends string ? true : false;

type A = IsString<string>; // true
type B = IsString<number>; // false
type C = IsString<"hello">; // true (string 리터럴도 string을 extends)

기본 문법은 T extends U ? X : Y 형태다. T가 U에 할당 가능하면 X, 아니면 Y가 된다.


내장 유틸리티 타입의 조건부 타입 구현

TypeScript 내장 유틸리티 타입들이 실제로 어떻게 구현되어 있는지 살펴보면 조건부 타입을 이해하는 데 도움이 된다.

// NonNullable 구현
type NonNullable<T> = T extends null | undefined ? never : T;

type E = NonNullable<string | null | undefined>; // string

// ReturnType 구현
type ReturnType<T extends (...args: any) => any>
  = T extends (...args: any) => infer R ? R : never;

function greet(): string { return "hello"; }
type GreetReturn = ReturnType<typeof greet>; // string

// Parameters 구현
type Parameters<T extends (...args: any) => any>
  = T extends (...args: infer P) => any ? P : never;

function add(a: number, b: number): number { return a + b; }
type AddParams = Parameters<typeof add>; // [number, number]

infer 키워드

infer는 조건부 타입 안에서 타입을 추론하고 변수처럼 캡처할 때 사용한다. extends 절 안에서만 쓸 수 있다.

// 배열의 원소 타입 추출
type ElementType<T> = T extends (infer E)[] ? E : never;

type NumArray = ElementType<number[]>;  // number
type StrArray = ElementType<string[]>;  // string

// Promise의 해결 타입 추출
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;

type Resolved = Awaited<Promise<Promise<string>>>; // string
// (실제 내장 Awaited<T>도 이와 유사하게 구현됨)

// 함수의 첫 번째 인자 타입 추출
type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;

type FA = FirstArg<(x: number, y: string) => void>; // number

분산 조건부 타입(Distributive Conditional Types)

제네릭 타입 파라미터에 유니온 타입을 넣으면 조건부 타입이 유니온의 각 멤버에 분산 적용된다.

type ToArray<T> = T extends any ? T[] : never;

type StrOrNumArray = ToArray<string | number>;
// = ToArray<string> | ToArray<number>
// = string[] | number[]

// 분산을 막으려면 튜플로 감싸면 됨
type ToArrayNoDist<T> = [T] extends [any] ? T[] : never;

type StrOrNumArray2 = ToArrayNoDist<string | number>;
// = (string | number)[]

실용적인 조건부 타입 패턴

// 특정 키만 선택적으로 만들기
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

type User = { id: number; name: string; email: string };
type PartialUser = PartialBy<User, "email">;
// = { id: number; name: string; email?: string }

// 함수 타입인지 판별
type IsFunction<T> = T extends (...args: any[]) => any ? true : false;

// 읽기 전용 키만 추출
type ReadonlyKeys<T> = {
  [K in keyof T]-?: (<U>() => U extends { [P in K]: T[P] } ? 1 : 2) extends
    (<U>() => U extends { -readonly [P in K]: T[P] } ? 1 : 2) ? never : K;
}[keyof T];

// 깊은 Partial
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

type Config = {
  server: { host: string; port: number };
  db: { url: string; name: string };
};
type PartialConfig = DeepPartial<Config>;
// server.host, server.port 등 모두 optional

템플릿 리터럴 타입과 조합

TypeScript 4.1부터 지원하는 템플릿 리터럴 타입과 조건부 타입을 조합하면 강력한 타입 변환이 가능하다.

type EventName<T extends string> = `on${Capitalize<T>}`;

type Click = EventName<"click">;   // "onClick"
type Focus = EventName<"focus">;   // "onFocus"
type Change = EventName<"change">; // "onChange"

// 객체의 모든 키를 이벤트 핸들러 이름으로 변환
type EventHandlers<T extends string> = {
  [K in T as EventName<K>]: () => void;
};

type Handlers = EventHandlers<"click" | "focus" | "change">;
// { onClick: () => void; onFocus: () => void; onChange: () => void }

정리

개념용도
제네릭 함수다양한 타입에 동작하는 재사용 가능한 함수
제네릭 제약 (extends)제네릭 타입에 조건 부여
keyof, typeof타입 레벨에서 키와 타입 참조
조건부 타입타입 레벨 분기 처리
infer조건부 타입 안에서 타입 추론·캡처
분산 조건부 타입유니온 타입에 조건부 타입 개별 적용

제네릭과 조건부 타입은 TypeScript 타입 시스템의 핵심 기능이다. 유틸리티 타입들이 내부적으로 이 둘을 조합해서 구현되므로, 직접 작성해보면서 익히는 게 가장 효과적이다.