TypeScript 타입 시스템 고급 — 조건부, 매핑, infer

고급 타입이 필요한 이유

TypeScript의 기본 타입과 제네릭만으로도 대부분의 코드를 다룰 수 있습니다. 하지만 라이브러리를 만들거나, 복잡한 데이터 변환 로직의 타입을 표현하거나, 기존 타입에서 새로운 타입을 파생할 때는 고급 타입 기법이 필요합니다.

이 글에서는 조건부 타입, 매핑된 타입, infer 키워드, 템플릿 리터럴 타입, 재귀 타입까지 다룹니다.

조건부 타입 (Conditional Types)

조건부 타입은 T extends U ? X : Y 형태로, 타입 수준에서 삼항 연산자를 사용하는 것과 같습니다.

// 기본 조건부 타입
type IsString<T> = T extends string ? "yes" : "no";

type A = IsString<string>;  // "yes"
type B = IsString<number>;  // "no"
type C = IsString<"hello">; // "yes" — 리터럴 타입도 string을 확장

// 실전 활용: API 응답 타입 분기
type ApiEndpoint = "/users" | "/posts" | "/comments";

type ApiResponse<T extends ApiEndpoint> = 
  T extends "/users"    ? { id: number; name: string; email: string }[] :
  T extends "/posts"    ? { id: number; title: string; body: string }[] :
  T extends "/comments" ? { id: number; postId: number; text: string }[] :
  never;

// 엔드포인트에 따라 응답 타입이 자동 결정됨
type UsersResponse = ApiResponse<"/users">;
// { id: number; name: string; email: string }[]

type PostsResponse = ApiResponse<"/posts">;
// { id: number; title: string; body: string }[]

// 분배 조건부 타입 (Distributive Conditional Types)
// 유니온 타입을 넣으면 각 멤버에 대해 조건이 분배됨
type ToArray<T> = T extends unknown ? T[] : never;

type StrOrNumArray = ToArray<string | number>;
// string[] | number[] — (string | number)[]가 아님!

// 분배를 방지하려면 대괄호로 감싸기
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;

type Combined = ToArrayNonDist<string | number>;
// (string | number)[]

infer 키워드

infer는 조건부 타입 내에서 타입을 추출할 때 사용합니다. “이 위치의 타입이 무엇이든 그것을 캡처하라”는 의미입니다.

// 함수의 반환 타입 추출 (ReturnType과 동일한 원리)
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;

function fetchUser() {
  return { id: 1, name: "홍길동", email: "hong@example.com" };
}

type UserData = MyReturnType<typeof fetchUser>;
// { id: number; name: string; email: string }

// 함수의 매개변수 타입 추출
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;

function createUser(name: string, age: number, email: string): void {
  console.log(name, age, email);
}

type CreateUserParams = MyParameters<typeof createUser>;
// [name: string, age: number, email: string]

// Promise 내부 타입 추출
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type Resolved = UnwrapPromise<Promise<string>>;   // string
type NotPromise = UnwrapPromise<number>;           // number

// 중첩 Promise도 재귀적으로 풀기
type DeepUnwrap<T> = T extends Promise<infer U> ? DeepUnwrap<U> : T;

type Deep = DeepUnwrap<Promise<Promise<Promise<number>>>>;
// number

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

type NumElement = ElementType<number[]>;   // number
type StrElement = ElementType<string[]>;   // string
type Mixed = ElementType<(string | number)[]>; // string | number

// 객체 값 타입 추출
type ValueOf<T> = T[keyof T];

const STATUS = {
  ACTIVE: "active",
  INACTIVE: "inactive",
  PENDING: "pending",
} as const;

type StatusValue = ValueOf<typeof STATUS>;
// "active" | "inactive" | "pending"

매핑된 타입 (Mapped Types)

매핑된 타입은 기존 타입의 각 속성을 순회하면서 새로운 타입을 생성합니다. { [K in keyof T]: ... } 형태입니다.

// 모든 속성을 선택적으로 (Partial 구현)
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// 모든 속성을 필수로 (Required 구현)
type MyRequired<T> = {
  [K in keyof T]-?: T[K]; // -?로 선택적 수정자 제거
};

// 모든 속성을 읽기 전용으로 (Readonly 구현)
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

// 실전: 모든 속성을 nullable로 변환
type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

interface User {
  id: number;
  name: string;
  email: string;
}

type NullableUser = Nullable<User>;
// { id: number | null; name: string | null; email: string | null }

// 키 재매핑 (as 절)
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<User>;
// { getId: () => number; getName: () => string; getEmail: () => string }

// 특정 타입의 속성만 필터링
type StringKeys<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

interface Product {
  id: number;
  title: string;
  description: string;
  price: number;
}

type StringProduct = StringKeys<Product>;
// { title: string; description: string } — number 속성은 제외됨

// 이벤트 핸들러 자동 생성
type EventHandlers<T> = {
  [K in keyof T as `on${Capitalize<string & K>}Change`]: (value: T[K]) => void;
};

interface FormFields {
  username: string;
  age: number;
  active: boolean;
}

type FormHandlers = EventHandlers<FormFields>;
// {
//   onUsernameChange: (value: string) => void;
//   onAgeChange: (value: number) => void;
//   onActiveChange: (value: boolean) => void;
// }

템플릿 리터럴 타입

TypeScript 4.1부터 문자열 리터럴 타입을 조합하여 새로운 문자열 타입을 생성할 수 있습니다.

// 기본 템플릿 리터럴 타입
type Greeting = `Hello, ${string}!`;
const g1: Greeting = "Hello, World!";  // OK
// const g2: Greeting = "Hi, World!";  // 에러!

// 유니온과 조합: 모든 경우의 수 생성
type Color = "red" | "green" | "blue";
type Size = "sm" | "md" | "lg";

type ColorSize = `${Color}-${Size}`;
// "red-sm" | "red-md" | "red-lg" | "green-sm" | "green-md" | "green-lg" | "blue-sm" | "blue-md" | "blue-lg"

// CSS 단위 타입
type CSSUnit = "px" | "rem" | "em" | "%";
type CSSValue = `${number}${CSSUnit}`;

const width: CSSValue = "100px";  // OK
const height: CSSValue = "2.5rem"; // OK
// const invalid: CSSValue = "100";  // 에러! 단위 없음

// 내장 문자열 유틸리티 타입
type Upper = Uppercase<"hello">;       // "HELLO"
type Lower = Lowercase<"HELLO">;       // "hello"
type Cap = Capitalize<"hello">;        // "Hello"
type Uncap = Uncapitalize<"Hello">;    // "hello"

// 실전: 라우터 파라미터 추출
type ExtractParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<`/${Rest}`>
    : T extends `${string}:${infer Param}`
      ? Param
      : never;

type Params = ExtractParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"

// 파라미터를 객체 타입으로 변환
type RouteParams<T extends string> = {
  [K in ExtractParams<T>]: string;
};

type UserPostParams = RouteParams<"/users/:userId/posts/:postId">;
// { userId: string; postId: string }

실전 활용: 타입 안전한 이벤트 시스템

지금까지 배운 기법을 조합하여 타입 안전한 이벤트 시스템을 만들어봅니다.

// 이벤트 맵 정의
interface EventMap {
  userLogin: { userId: number; timestamp: Date };
  userLogout: { userId: number };
  pageView: { path: string; referrer: string | null };
  purchase: { productId: number; quantity: number; total: number };
}

// 타입 안전한 이벤트 이미터
class TypedEventEmitter<T extends Record<string, unknown>> {
  private handlers = new Map<keyof T, Set<(data: unknown) => void>>();

  on<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, new Set());
    }
    this.handlers.get(event)!.add(handler as (data: unknown) => void);
  }

  off<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
    this.handlers.get(event)?.delete(handler as (data: unknown) => void);
  }

  emit<K extends keyof T>(event: K, data: T[K]): void {
    this.handlers.get(event)?.forEach((handler) => handler(data));
  }
}

// 사용: 이벤트 이름과 데이터 타입이 연동됨
const emitter = new TypedEventEmitter<EventMap>();

emitter.on("userLogin", (data) => {
  // data 타입이 { userId: number; timestamp: Date }로 추론됨
  console.log(`사용자 ${data.userId} 로그인: ${data.timestamp}`);
});

emitter.on("purchase", (data) => {
  // data 타입이 { productId: number; quantity: number; total: number }로 추론됨
  console.log(`구매: 상품 ${data.productId}, ${data.quantity}개, 총 ${data.total}원`);
});

// 타입 검사 적용
emitter.emit("userLogin", { userId: 1, timestamp: new Date() }); // OK
// emitter.emit("userLogin", { userId: "abc" }); // 에러! userId는 number
// emitter.emit("unknown", {});                 // 에러! "unknown"은 EventMap에 없음

정리

  • 조건부 타입(T extends U ? X : Y)은 타입 수준의 분기 로직입니다. 유니온 타입에 적용하면 분배(distributive) 동작을 합니다.
  • infer는 조건부 타입 내에서 타입을 추출합니다. 함수 반환 타입, Promise 내부 타입, 배열 요소 타입 등을 꺼낼 때 사용합니다.
  • 매핑된 타입은 기존 타입의 속성을 순회하며 변환합니다. as 절로 키를 재매핑하고, 조건부 타입으로 필터링할 수 있습니다.
  • 템플릿 리터럴 타입은 문자열 패턴을 타입으로 표현합니다. 라우터, CSS, 이벤트 이름 등에서 강력한 타입 검사를 제공합니다.
  • 고급 타입은 강력하지만, 팀원이 이해할 수 있는 수준으로 유지하는 것이 중요합니다. 과도한 타입 체조(type gymnastics)는 유지보수를 어렵게 만들 수 있습니다.

이 글이 도움이 되었나요?