본문으로 이동
·18분 읽기

TypeScript 고급 타입: 조건부 타입, 매핑된 타입, 템플릿 리터럴 타입의 마법

TypeScript의 가장 강력한 타입 기능을 심층 분석합니다. 조건부 타입, 매핑된 타입, infer 키워드, 템플릿 리터럴 타입까지. 실무에서 실제로 사용하는 패턴들을 다룹니다.

공유:X / TwitterLinkedIn

대부분의 TypeScript 개발자는 같은 지점에서 정체됩니다. 인터페이스, 제네릭, 유니온 타입 정도는 알고 있죠. React 컴포넌트에 타입을 붙일 수 있고, 빨간 밑줄을 없앨 수도 있습니다. 하지만 타입 시스템을 필요악 정도로 취급합니다. 버그를 방지하기 위해 존재하는 것이지, 더 나은 소프트웨어 설계를 적극적으로 도와주는 것은 아니라고 생각하는 거죠.

저도 약 2년간 그 정체기에 머물렀습니다. 저를 거기서 끌어낸 것은 TypeScript의 타입 시스템 자체가 하나의 프로그래밍 언어라는 깨달음이었습니다. 조건문, 반복, 패턴 매칭, 재귀, 문자열 조작까지 갖추고 있습니다. 이걸 체화하면 모든 것이 달라집니다. 컴파일러와 싸우는 대신 협업하기 시작하는 거죠.

이 글에서는 제가 프로덕션 코드에서 꾸준히 사용하는 타입 레벨 기능들을 다룹니다. 학문적 연습이 아닙니다. 실제 버그를 잡아준 실전 패턴들입니다.

타입 레벨 프로그래밍의 사고방식#

문법에 들어가기 전에, 타입에 대한 사고방식을 재정립하고 싶습니다.

값 레벨 프로그래밍에서는 런타임에 데이터를 변환하는 함수를 작성합니다:

typescript
function extractIds(users: User[]): number[] {
  return users.map(u => u.id);
}

타입 레벨 프로그래밍에서는 컴파일 타임에 다른 타입을 변환하는 타입을 작성합니다:

typescript
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];

멘탈 모델은 동일합니다. 입력, 변환, 출력. 차이점은 타입 레벨 코드가 컴파일 중에 실행되며 런타임에는 실행되지 않는다는 것입니다. JavaScript를 전혀 생성하지 않습니다. 유일한 목적은 불가능한 상태를 표현할 수 없게 만드는 것입니다.

네 개의 매개변수 중 세 개가 특정 조합에서만 의미가 있는 함수를 작성한 적이 있거나, 반환 타입이 전달하는 값에 따라 달라지는 함수를 작성한 적이 있다면, 이미 타입 레벨 프로그래밍이 필요했던 겁니다. 단지 TypeScript가 그것을 표현할 수 있다는 것을 몰랐을 뿐이죠.

조건부 타입#

조건부 타입은 타입 시스템의 if/else입니다. 문법은 삼항 연산자와 비슷하게 생겼습니다:

typescript
type IsString<T> = T extends string ? true : false;
 
type A = IsString<"hello">; // true
type B = IsString<42>;      // false
type C = IsString<string>;  // true

여기서 extends 키워드는 상속을 의미하지 않습니다. "할당 가능한가"를 의미합니다. "T가 string의 형태에 맞는가?"라고 생각하면 됩니다.

유틸리티 타입 직접 만들기#

TypeScript 내장 유틸리티 타입 몇 가지를 직접 구현해 보면서 어떻게 동작하는지 이해해 봅시다.

NonNullable — 유니온에서 nullundefined를 제거합니다:

typescript
type MyNonNullable<T> = T extends null | undefined ? never : T;
 
type Test = MyNonNullable<string | null | undefined>;
// Result: string

never 타입은 "공집합"입니다. 유니온에서 무언가를 제거하는 역할을 합니다. 조건부 타입의 분기가 never로 해석되면, 해당 유니온 멤버가 그냥 사라집니다.

Extract — 제약 조건에 맞는 유니온 멤버만 유지합니다:

typescript
type MyExtract<T, U> = T extends U ? T : never;
 
type Numbers = Extract<string | number | boolean, number | boolean>;
// Result: number | boolean

Exclude — 반대로, 일치하는 멤버를 제거합니다:

typescript
type MyExclude<T, U> = T extends U ? never : T;
 
type WithoutStrings = Exclude<string | number | boolean, string>;
// Result: number | boolean

분배적 조건부 타입#

여기서부터 흥미로워지고, 대부분의 사람들이 혼란스러워하는 부분입니다. 유니온 타입을 조건부 타입에 전달하면, TypeScript는 유니온의 각 멤버에 대해 개별적으로 조건을 분배합니다.

typescript
type ToArray<T> = T extends unknown ? T[] : never;
 
type Result = ToArray<string | number>;
// 예상: (string | number)[]
// 실제 결과: string[] | number[]

TypeScript는 (string | number) extends unknown을 평가하지 않습니다. 대신 string extends unknownnumber extends unknown을 별도로 평가한 다음 결과를 유니온으로 합칩니다.

이것이 ExtractExclude가 그렇게 동작하는 이유입니다. 분배는 T가 "벌거벗은" 타입 매개변수(아무것도 감싸지 않은 상태)일 때 자동으로 발생합니다.

분배를 방지하고 싶다면, 양쪽을 튜플로 감싸면 됩니다:

typescript
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
 
type Result = ToArrayNonDist<string | number>;
// Result: (string | number)[]

이 문제에 물린 횟수가 말하기 부끄러울 정도입니다. 조건부 타입이 유니온 입력에 대해 예상치 못한 결과를 보여준다면, 거의 항상 분배가 원인입니다.

실전 예제: API 응답 핸들러#

성공하거나 실패할 수 있는 API 응답을 다룰 때 사용하는 패턴입니다:

typescript
type ApiResponse<T> =
  | { status: "success"; data: T; error: never }
  | { status: "error"; data: never; error: string };
 
type ExtractData<R> = R extends { status: "success"; data: infer D } ? D : never;
type ExtractError<R> = R extends { status: "error"; error: infer E } ? E : never;
 
type UserResponse = ApiResponse<{ id: number; name: string }>;
 
type UserData = ExtractData<UserResponse>;
// Result: { id: number; name: string }
 
type UserError = ExtractError<UserResponse>;
// Result: string

모든 응답 형태에 대해 수동으로 타입 가드를 작성하는 것보다 훨씬 깔끔합니다.

infer 키워드#

infer는 타입을 위한 패턴 매칭입니다. 조건부 타입 내부에서 TypeScript가 알아서 추론해 줄 타입 변수를 선언할 수 있게 해줍니다.

이렇게 생각하면 됩니다: "이 타입이 특정 형태를 가지고 있다는 것을 알고 있으니, 내가 관심 있는 부분을 추출해줘."

반환 타입 추출#

typescript
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
 
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // boolean

TypeScript는 (...args) => something 패턴을 보고, 여러분의 함수를 이에 맞추어 매칭한 다음, 반환 타입을 R에 할당합니다.

매개변수 타입 추출#

typescript
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
 
type Fn = (x: number, y: string) => void;
type Params = MyParameters<Fn>; // [x: number, y: string]

Promise 언래핑#

비동기 함수를 다룰 때 항상 사용하는 타입입니다:

typescript
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
 
type A = UnpackPromise<Promise<string>>;  // string
type B = UnpackPromise<Promise<number[]>>; // number[]
type C = UnpackPromise<string>;            // string (passthrough)

깊게 중첩된 Promise의 경우:

typescript
type DeepUnpackPromise<T> = T extends Promise<infer U>
  ? DeepUnpackPromise<U>
  : T;
 
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Result: number

배열 요소 타입 추출#

typescript
type ElementOf<T> = T extends (infer E)[] ? E : never;
 
type Items = ElementOf<string[]>;         // string
type Mixed = ElementOf<(string | number)[]>; // string | number

하나의 타입에서 여러 개의 infer 사용#

infer를 여러 번 사용해서 서로 다른 부분을 추출할 수 있습니다:

typescript
type FirstAndLast<T extends unknown[]> =
  T extends [infer First, ...unknown[], infer Last]
    ? { first: First; last: Last }
    : never;
 
type Result = FirstAndLast<[1, 2, 3, 4]>;
// Result: { first: 1; last: 4 }

실전: 타입 안전한 이벤트 추출#

이벤트 기반 시스템에서 사용하는 패턴입니다:

typescript
type EventMap = {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "order:created": { orderId: string; total: number };
  "order:shipped": { orderId: string; trackingId: string };
};
 
type EventName = keyof EventMap;
type EventPayload<E extends EventName> = EventMap[E];
 
// 사용자 관련 이벤트만 추출
type UserEvents = Extract<EventName, `user:${string}`>;
// Result: "user:login" | "user:logout"
 
// 사용자 이벤트의 페이로드만 가져오기
type UserEventPayloads = EventPayload<UserEvents>;
// Result: { userId: string; timestamp: number } | { userId: string }

매핑된 타입#

매핑된 타입을 사용하면 기존 타입의 각 프로퍼티를 변환하여 새로운 객체 타입을 만들 수 있습니다. 타입 시스템의 map()이라고 할 수 있습니다.

기본 사항#

typescript
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};
 
type Partial<T> = {
  [K in keyof T]?: T[K];
};

keyof T는 모든 프로퍼티 키의 유니온을 제공합니다. [K in ...]은 각 키를 순회합니다. T[K]는 해당 프로퍼티의 타입입니다.

유틸리티 타입을 처음부터 만들기#

Required — 모든 프로퍼티를 필수로 만듭니다:

typescript
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

-? 구문은 선택적 수정자를 제거합니다. 마찬가지로 -readonly는 readonly를 제거합니다.

Pick — 특정 프로퍼티를 선택합니다:

typescript
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};
 
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}
 
type PublicUser = MyPick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }

Omit — 특정 프로퍼티를 제거합니다:

typescript
type MyOmit<T, K extends keyof T> = {
  [P in Exclude<keyof T, K>]: T[P];
};
 
type SafeUser = MyOmit<User, "password">;
// { id: number; name: string; email: string }

Record — 특정 키와 값 타입으로 객체 타입을 만듭니다:

typescript
type MyRecord<K extends string | number | symbol, V> = {
  [P in K]: V;
};
 
type StatusMap = MyRecord<"active" | "inactive" | "banned", boolean>;
// { active: boolean; inactive: boolean; banned: boolean }

as를 사용한 키 리매핑#

TypeScript 4.1에서 도입된 키 리매핑을 사용하면 프로퍼티 이름을 변환할 수 있습니다:

typescript
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
 
interface Person {
  name: string;
  age: number;
  email: string;
}
 
type PersonGetters = Getters<Person>;
// {
//   getName: () => string;
//   getAge: () => number;
//   getEmail: () => string;
// }

never로 리매핑해서 프로퍼티를 필터링할 수도 있습니다:

typescript
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};
 
type StringProps = OnlyStrings<Person>;
// { name: string; email: string }

이것은 놀라울 정도로 강력합니다. 프로퍼티를 순회하면서 동시에 필터링하고, 이름까지 변환하는 것이 모두 타입 레벨에서 이루어집니다.

실전 예제: 폼 유효성 검사 타입#

여러 폼 라이브러리에서 사용했던 패턴입니다:

typescript
interface FormFields {
  username: string;
  email: string;
  age: number;
  bio: string;
}
 
type ValidationErrors<T> = {
  [K in keyof T]?: string[];
};
 
type TouchedFields<T> = {
  [K in keyof T]?: boolean;
};
 
type DirtyFields<T> = {
  [K in keyof T]?: boolean;
};
 
type FormState<T> = {
  values: T;
  errors: ValidationErrors<T>;
  touched: TouchedFields<T>;
  dirty: DirtyFields<T>;
  isValid: boolean;
  isSubmitting: boolean;
};
 
// 이제 폼 상태가 필드를 기반으로 완전히 타입화됩니다
type MyFormState = FormState<FormFields>;

FormFields에 필드를 추가하면 모든 관련 타입이 자동으로 업데이트됩니다. 필드를 제거하면 컴파일러가 모든 참조를 잡아냅니다. 이런 것이 전체 카테고리의 버그를 예방하는 것입니다.

템플릿 리터럴 타입#

템플릿 리터럴 타입을 사용하면 타입 레벨에서 문자열을 조작할 수 있습니다. JavaScript 템플릿 리터럴과 동일한 백틱 구문을 사용하지만, 타입용입니다.

기본 문자열 조작#

typescript
type Greeting = `Hello, ${string}`;
 
const a: Greeting = "Hello, world";    // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world";    // Error!

TypeScript는 내장 문자열 조작 타입도 제공합니다:

typescript
type Upper = Uppercase<"hello">;     // "HELLO"
type Lower = Lowercase<"HELLO">;     // "hello"
type Cap = Capitalize<"hello">;      // "Hello"
type Uncap = Uncapitalize<"Hello">;  // "hello"

이벤트 이름 패턴#

템플릿 리터럴 타입이 진정으로 빛나는 곳입니다. 타입 안전한 이벤트 시스템에 사용하는 패턴입니다:

typescript
type EventNames<T extends string> = `${T}Changed` | `${T}Deleted` | `${T}Created`;
 
type ModelEvents = EventNames<"user" | "order" | "product">;
// "userChanged" | "userDeleted" | "userCreated"
// | "orderChanged" | "orderDeleted" | "orderCreated"
// | "productChanged" | "productDeleted" | "productCreated"

두 개의 타입 매개변수로 아홉 개의 이벤트가 생성됩니다. 그리고 모두 타입 안전합니다.

CSS-in-TypeScript 패턴#

템플릿 리터럴 타입은 타입 레벨에서 CSS 패턴을 강제할 수 있습니다:

typescript
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
 
function setWidth(value: CSSValue): void {
  // implementation
}
 
setWidth("100px");   // OK
setWidth("2.5rem");  // OK
setWidth("50%");     // OK
// setWidth("100");  // Error: not a valid CSS value
// setWidth("big");  // Error

템플릿 리터럴로 문자열 파싱#

템플릿 리터럴 타입과 infer를 함께 사용해서 문자열을 파싱할 수 있습니다:

typescript
type ParseRoute<T extends string> =
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? Param | ParseRoute<`/${Rest}`>
    : T extends `${infer _Start}:${infer Param}`
      ? Param
      : never;
 
type RouteParams = ParseRoute<"/users/:userId/posts/:postId">;
// Result: "userId" | "postId"

이것이 tRPC 같은 타입 안전 라우팅 라이브러리가 작동하는 기반입니다. 문자열 /users/:userId/posts/:postId가 타입 레벨에서 파싱되어 매개변수 이름을 추출합니다.

템플릿 리터럴과 매핑된 타입의 결합#

여기서부터 진짜 강력해집니다:

typescript
type EventHandlers<T extends Record<string, unknown>> = {
  [K in keyof T as `on${Capitalize<string & K>}Change`]: (
    newValue: T[K],
    oldValue: T[K]
  ) => void;
};
 
interface ThemeConfig {
  color: string;
  fontSize: number;
  darkMode: boolean;
}
 
type ThemeHandlers = EventHandlers<ThemeConfig>;
// {
//   onColorChange: (newValue: string, oldValue: string) => void;
//   onFontSizeChange: (newValue: number, oldValue: number) => void;
//   onDarkModeChange: (newValue: boolean, oldValue: boolean) => void;
// }

간단한 설정 인터페이스로부터 올바른 매개변수 타입을 가진 완전히 타입화된 이벤트 핸들러를 얻을 수 있습니다. 이런 종류의 타입 레벨 코드가 실제로 개발 시간을 절약해 줍니다.

고급: 점 표기법 경로 타입#

더 고급 패턴입니다. 중첩된 객체를 통과하는 가능한 모든 점 표기법 경로를 생성합니다:

typescript
type DotPaths<T, Prefix extends string = ""> = T extends object
  ? {
      [K in keyof T & string]: T[K] extends object
        ? `${Prefix}${K}` | DotPaths<T[K], `${Prefix}${K}.`>
        : `${Prefix}${K}`;
    }[keyof T & string]
  : never;
 
interface Config {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
  server: {
    port: number;
  };
}
 
type ConfigPaths = DotPaths<Config>;
// "database" | "database.host" | "database.port"
// | "database.credentials" | "database.credentials.username"
// | "database.credentials.password" | "server" | "server.port"

이제 가능한 모든 경로를 자동완성하고 각 경로에 대해 올바른 타입을 반환하는 getConfig 함수를 작성할 수 있습니다:

typescript
type GetValueByPath<T, P extends string> =
  P extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? GetValueByPath<T[Key], Rest>
      : never
    : P extends keyof T
      ? T[P]
      : never;
 
function getConfig<P extends ConfigPaths>(path: P): GetValueByPath<Config, P> {
  // implementation
  const keys = path.split(".");
  let result: unknown = config;
  for (const key of keys) {
    result = (result as Record<string, unknown>)[key];
  }
  return result as GetValueByPath<Config, P>;
}
 
const host = getConfig("database.host");         // type: string
const port = getConfig("database.port");         // type: number
const username = getConfig("database.credentials.username"); // type: string

완벽한 자동완성, 완벽한 타입 안전성, 타입으로 인한 런타임 오버헤드는 제로입니다.

재귀 타입#

TypeScript는 재귀적 타입 정의를 지원하여 임의로 중첩된 구조를 처리할 수 있습니다.

Deep Partial#

내장 Partial<T>는 최상위 프로퍼티만 선택적으로 만듭니다. 중첩된 객체에는 재귀가 필요합니다:

typescript
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object
    ? T[K] extends Array<infer U>
      ? Array<DeepPartial<U>>
      : DeepPartial<T[K]>
    : T[K];
};
 
interface Settings {
  theme: {
    colors: {
      primary: string;
      secondary: string;
    };
    fonts: {
      heading: string;
      body: string;
    };
  };
  notifications: {
    email: boolean;
    push: boolean;
  };
}
 
type PartialSettings = DeepPartial<Settings>;
// 모든 중첩된 프로퍼티가 이제 선택적입니다

배열 체크에 주의하세요. 이것이 없으면 배열이 객체로 취급되어 숫자 인덱스가 선택적이 되는데, 원하는 결과가 아닙니다.

Deep Readonly#

같은 패턴에 다른 수정자를 적용합니다:

typescript
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : T[K] extends Array<infer U>
        ? ReadonlyArray<DeepReadonly<U>>
        : DeepReadonly<T[K]>
    : T[K];
};

Function 체크가 중요합니다. 이것이 없으면 함수 프로퍼티의 내부 구조에 readonly가 적용되는데, 이는 말이 안 됩니다.

JSON 타입#

고전적인 재귀 타입입니다. 유효한 모든 JSON 값을 표현합니다:

typescript
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
 
function parseJson(input: string): JsonValue {
  return JSON.parse(input);
}
 
function isJsonObject(value: JsonValue): value is JsonObject {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

JSON 파싱에 any를 사용하는 것보다 훨씬 낫습니다. 소비자에게 "무언가를 받게 될 테지만 사용하기 전에 좁혀야 합니다"라고 알려주는데, 이것이 정확히 사실입니다.

중첩 메뉴를 위한 재귀 타입#

실용적인 예제입니다. 네비게이션 메뉴 구조에 타입을 부여합니다:

typescript
interface MenuItem {
  label: string;
  href: string;
  icon?: string;
  children?: MenuItem[];
}
 
type Menu = MenuItem[];
 
const navigation: Menu = [
  {
    label: "Products",
    href: "/products",
    children: [
      {
        label: "Software",
        href: "/products/software",
        children: [
          { label: "IDE", href: "/products/software/ide" },
          { label: "CLI Tools", href: "/products/software/cli" },
        ],
      },
      { label: "Hardware", href: "/products/hardware" },
    ],
  },
  { label: "About", href: "/about" },
];

재귀적 children 프로퍼티가 완전한 타입 안전성과 함께 무한 중첩을 제공합니다.

실전 패턴#

프로덕션 시스템에서 실제로 사용했던 패턴들을 공유하겠습니다.

타입을 활용한 빌더 패턴#

타입 시스템이 무엇이 설정되었는지 추적하면 빌더 패턴이 훨씬 더 유용해집니다:

typescript
type RequiredKeys = "host" | "port" | "database";
 
type BuilderState = {
  [K in RequiredKeys]?: true;
};
 
class DatabaseConfigBuilder<State extends BuilderState = {}> {
  private config: Partial<{
    host: string;
    port: number;
    database: string;
    ssl: boolean;
    poolSize: number;
  }> = {};
 
  setHost(host: string): DatabaseConfigBuilder<State & { host: true }> {
    this.config.host = host;
    return this as unknown as DatabaseConfigBuilder<State & { host: true }>;
  }
 
  setPort(port: number): DatabaseConfigBuilder<State & { port: true }> {
    this.config.port = port;
    return this as unknown as DatabaseConfigBuilder<State & { port: true }>;
  }
 
  setDatabase(db: string): DatabaseConfigBuilder<State & { database: true }> {
    this.config.database = db;
    return this as unknown as DatabaseConfigBuilder<State & { database: true }>;
  }
 
  setSsl(ssl: boolean): DatabaseConfigBuilder<State> {
    this.config.ssl = ssl;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  setPoolSize(size: number): DatabaseConfigBuilder<State> {
    this.config.poolSize = size;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  build(
    this: DatabaseConfigBuilder<{ host: true; port: true; database: true }>
  ): Required<Pick<typeof this.config, "host" | "port" | "database">> &
    Partial<Pick<typeof this.config, "ssl" | "poolSize">> {
    return this.config as ReturnType<typeof this.build>;
  }
}
 
// 이건 컴파일됩니다:
const config = new DatabaseConfigBuilder()
  .setHost("localhost")
  .setPort(5432)
  .setDatabase("myapp")
  .setSsl(true)
  .build();
 
// 이건 컴파일 타임에 실패합니다 — .setDatabase()가 빠져있습니다:
// const bad = new DatabaseConfigBuilder()
//   .setHost("localhost")
//   .setPort(5432)
//   .build(); // Error: 'database' is missing

build() 메서드는 모든 필수 필드가 설정되었을 때만 호출 가능합니다. 이것은 런타임이 아닌 컴파일 타임에 잡힙니다.

타입 안전한 이벤트 이미터#

페이로드 타입이 강제되는 이벤트 이미터입니다:

typescript
type EventMap = Record<string, unknown>;
 
class TypedEventEmitter<Events extends EventMap> {
  private listeners: {
    [K in keyof Events]?: Array<(payload: Events[K]) => void>;
  } = {};
 
  on<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(listener);
  }
 
  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.listeners[event]?.forEach((listener) => listener(payload));
  }
 
  off<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    const handlers = this.listeners[event];
    if (handlers) {
      this.listeners[event] = handlers.filter((h) => h !== listener) as typeof handlers;
    }
  }
}
 
// 사용법
interface AppEvents {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "notification:new": { message: string; level: "info" | "warning" | "error" };
}
 
const emitter = new TypedEventEmitter<AppEvents>();
 
// 완전히 타입화됨 — IDE가 이벤트 이름과 페이로드 형태를 자동완성합니다
emitter.on("user:login", (payload) => {
  console.log(payload.userId);     // string
  console.log(payload.timestamp);  // number
});
 
// 타입 에러: 페이로드가 일치하지 않음
// emitter.emit("user:login", { userId: "123" });
// Error: missing 'timestamp'
 
// 타입 에러: 이벤트가 존재하지 않음
// emitter.on("user:signup", () => {});
// Error: "user:signup" is not in AppEvents

이 패턴을 세 개의 서로 다른 프로덕션 프로젝트에서 사용했습니다. 이벤트 관련 버그의 전체 카테고리를 컴파일 타임에 잡아냅니다.

판별 유니온과 완전성 검사#

판별 유니온은 아마도 TypeScript에서 가장 유용한 단일 패턴일 것입니다. 완전성 검사와 결합하면 모든 경우를 처리하도록 보장합니다:

typescript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      // 이 줄이 완전성 검사를 보장합니다
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

누군가 새로운 도형 변형(예: "pentagon")을 추가하면, never에 값을 할당할 수 없기 때문에 이 함수의 컴파일이 실패합니다. 컴파일러가 모든 경우를 처리하도록 강제하는 것입니다.

저는 이것을 헬퍼 함수로 더 발전시킵니다:

typescript
function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      assertNever(shape, `Unknown shape kind: ${(shape as Shape).kind}`);
  }
}

타입 안전한 상태 머신#

판별 유니온은 상태 머신도 아름답게 모델링합니다:

typescript
type ConnectionState =
  | { status: "disconnected" }
  | { status: "connecting"; attempt: number }
  | { status: "connected"; socket: WebSocket; connectedAt: Date }
  | { status: "error"; error: Error; lastAttempt: Date };
 
type ConnectionAction =
  | { type: "CONNECT" }
  | { type: "CONNECTED"; socket: WebSocket }
  | { type: "DISCONNECT" }
  | { type: "ERROR"; error: Error }
  | { type: "RETRY" };
 
function connectionReducer(
  state: ConnectionState,
  action: ConnectionAction
): ConnectionState {
  switch (state.status) {
    case "disconnected":
      if (action.type === "CONNECT") {
        return { status: "connecting", attempt: 1 };
      }
      return state;
 
    case "connecting":
      if (action.type === "CONNECTED") {
        return {
          status: "connected",
          socket: action.socket,
          connectedAt: new Date(),
        };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "connected":
      if (action.type === "DISCONNECT") {
        state.socket.close();
        return { status: "disconnected" };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "error":
      if (action.type === "RETRY") {
        return { status: "connecting", attempt: 1 };
      }
      if (action.type === "DISCONNECT") {
        return { status: "disconnected" };
      }
      return state;
 
    default:
      assertNever(state, "Unknown connection state");
  }
}

각 상태는 해당 상태에서 의미가 있는 프로퍼티만 가지고 있습니다. 연결이 끊긴 상태에서 socket에 접근할 수 없고, 연결된 상태에서 error에 접근할 수 없습니다. 타입 시스템이 상태 머신의 제약 조건을 강제합니다.

도메인 안전성을 위한 브랜디드 타입#

필수적이라고 생각하는 패턴 하나 더 있습니다. 브랜디드 타입을 사용하여 같은 기본 타입을 공유하는 값들을 혼동하는 것을 방지합니다:

typescript
type Brand<T, B extends string> = T & { readonly __brand: B };
 
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
 
function createUserId(id: string): UserId {
  return id as UserId;
}
 
function createOrderId(id: string): OrderId {
  return id as OrderId;
}
 
function getUser(id: UserId): Promise<User> {
  // ...
}
 
function getOrder(id: OrderId): Promise<Order> {
  // ...
}
 
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
 
getUser(userId);   // OK
// getUser(orderId); // Error! OrderId is not assignable to UserId

UserIdOrderId 모두 런타임에서는 문자열입니다. 하지만 컴파일 타임에서는 서로 다른 타입입니다. 사용자 ID가 필요한 곳에 주문 ID를 전달하는 것이 말 그대로 불가능합니다. 이 패턴을 사용한 모든 프로젝트에서 실제 버그를 잡아냈습니다.

흔한 함정#

고급 타입은 강력하지만 함정이 따릅니다. 어려운 방법으로 배운 것들을 공유합니다.

순환 참조 제한#

TypeScript에는 타입의 재귀 깊이 제한이 있습니다 (현재 약 50 레벨 정도이며 달라질 수 있습니다). 너무 깊이 들어가면 무시무시한 "Type instantiation is excessively deep and possibly infinite" 에러를 만나게 됩니다.

typescript
// 깊게 중첩된 객체에서 재귀 제한에 걸립니다
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
 
// 해결책: 깊이 카운터 추가
type DeepPartialSafe<T, Depth extends number[] = []> =
  Depth["length"] extends 10
    ? T
    : {
        [K in keyof T]?: T[K] extends object
          ? DeepPartialSafe<T[K], [...Depth, 0]>
          : T[K];
      };

깊이 카운터 트릭은 각 재귀 단계마다 길어지는 튜플을 사용합니다. 제한에 도달하면 재귀가 멈춥니다.

성능 영향#

복잡한 타입은 에디터와 빌드 시간을 크게 느리게 할 수 있습니다. 단 하나의 과도하게 영리한 타입이 모든 키 입력의 피드백 루프에 3초를 추가하는 프로젝트를 본 적이 있습니다.

경고 신호:

  • IDE의 자동완성이 2초 이상 걸리는 경우
  • 타입을 추가한 후 tsc --noEmit이 눈에 띄게 느려지는 경우
  • "Type instantiation is excessively deep" 에러가 나타나는 경우
typescript
// 이건 너무 영리함 — 조합 폭발이 발생합니다
type AllCombinations<T extends string[]> = T extends [
  infer First extends string,
  ...infer Rest extends string[]
]
  ? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
  : "";
 
// 5-6개 이상의 요소로는 이렇게 하지 마세요

고급 타입을 사용하지 말아야 할 때#

이것이 가장 중요한 섹션일 수 있습니다. 고급 타입은 버그를 방지하거나 개발자 경험을 향상시킬 때 사용해야 합니다. 과시하기 위해 사용해서는 안 됩니다.

사용하지 말아야 할 때:

  • 간단한 Record<string, unknown>으로 충분할 때
  • 타입이 한 곳에서만 사용되고 구체적인 타입이 더 명확할 때
  • 타입 디버깅에 들이는 시간이 절약할 시간보다 많을 때
  • 팀이 읽거나 유지보수할 수 없을 때
  • 런타임 체크가 더 적절할 때

사용해야 할 때:

  • 여러 타입에 걸쳐 반복되는 패턴이 있을 때 (매핑된 타입)
  • 함수의 반환 타입이 입력에 따라 달라질 때 (조건부 타입)
  • 자체 문서화가 필요한 라이브러리 API를 구축할 때
  • 불법적인 상태를 표현 불가능하게 만들고 싶을 때
  • 코드 리뷰에서 같은 카테고리의 버그가 반복적으로 나타나는 것에 지쳤을 때
typescript
// 과도한 엔지니어링 — 간단한 설정에는 이렇게 하지 마세요
type Config = DeepReadonly<
  DeepRequired<
    Merge<DefaultConfig, Partial<UserConfig>>
  >
>;
 
// 그냥 이렇게 하세요
interface Config {
  readonly host: string;
  readonly port: number;
  readonly debug: boolean;
}

제 경험 법칙: 타입을 설명하는 데 걸리는 시간이 타입이 방지하는 버그를 설명하는 시간보다 길다면, 단순화하세요.

복잡한 타입 디버깅#

복잡한 타입이 작동하지 않을 때, TypeScript가 무엇으로 해석했는지 "보기" 위해 이 헬퍼를 사용합니다:

typescript
// IDE 호버 툴팁에서 타입을 검사하기 위해 펼칩니다
type Prettify<T> = {
  [K in keyof T]: T[K];
} & {};
 
// 디버깅에 사용
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// 이제 IDE에서 Debug 위에 마우스를 올려 해석된 타입을 확인하세요

& {} 트릭은 TypeScript가 타입 별칭을 보여주는 대신 즉시 타입을 평가하도록 강제합니다. 이 트릭이 수 시간의 혼란을 줄여줬습니다.

또 다른 기법 — 격리하고 점진적으로 테스트하기:

typescript
// 이것을 한꺼번에 디버깅하는 대신:
type Final = StepThree<StepTwo<StepOne<Input>>>;
 
// 분리하세요:
type AfterStep1 = StepOne<Input>;       // 호버하여 확인
type AfterStep2 = StepTwo<AfterStep1>;  // 호버하여 확인
type AfterStep3 = StepThree<AfterStep2>; // 호버하여 확인

요약#

  • 조건부 타입 (T extends U ? X : Y)은 타입을 위한 if/else입니다. 유니온에서 분배 동작에 주의하세요.
  • **infer**는 패턴 매칭입니다. 함수 시그니처, Promise, 배열 같은 구조에서 타입을 추출하는 데 사용하세요.
  • 매핑된 타입 ({ [K in keyof T]: ... })은 프로퍼티를 순회합니다. 키 리매핑과 필터링을 위해 as와 결합하세요.
  • 템플릿 리터럴 타입은 타입 레벨에서 문자열을 조작합니다. 매핑된 타입과 결합하면 API 설계에 놀라울 정도로 강력합니다.
  • 재귀 타입은 중첩된 구조를 처리하지만 컴파일러 폭발을 방지하기 위해 깊이 제한이 필요합니다.
  • 판별 유니온과 완전성 검사는 TypeScript에서 가장 가치 높은 단일 패턴입니다. 어디에서나 사용하세요.
  • 브랜디드 타입은 같은 기본 타입을 공유하는 값들의 혼동을 방지합니다. 구현이 간단하고 영향은 큽니다.
  • 타입을 과도하게 엔지니어링하지 마세요. 타입이 방지하는 버그보다 이해하기 어렵다면, 단순화하세요. 목표는 코드베이스를 더 안전하게 만드는 것이지, 타입 골프 대회에서 우승하는 것이 아닙니다.

TypeScript 타입 시스템은 튜링 완전합니다. 이는 거의 모든 것을 할 수 있다는 뜻입니다. 기술은 언제 해야 하는지를 아는 것입니다.

관련 게시물