TypeScript 5.x 고급 타입 패턴: 실무에서 바로 쓰는 10가지 기법

프론트엔드

TypeScript타입 시스템고급 타입타입 가드제네릭

이 글은 누구를 위한 것인가

  • TypeScript를 쓰는데 any를 자주 쓰거나 타입 에러에 막히는 개발자
  • 제네릭은 아는데 Conditional Types, Template Literals는 낯선 팀
  • 타입 시스템을 활용해 런타임 에러를 컴파일 타임에 잡고 싶은 엔지니어

들어가며

TypeScript를 "타입 달린 JavaScript"로만 쓰면 타입 시스템의 50%도 활용하지 못하는 것이다. TypeScript 5.x의 고급 타입 시스템을 활용하면 API 응답 타입, 이벤트 시스템, 설정 객체 등을 완전히 타입 안전하게 만들 수 있다.

이 글에서는 실무에서 바로 적용할 수 있는 10가지 패턴을 다룬다. 이론이 아닌 "이 패턴이 어떤 실제 문제를 해결하는가"에 초점을 맞춘다.

이 글은 bluefoxdev.kr의 TypeScript 활용 심화 가이드 를 참고하고, 실무 패턴 관점에서 확장하여 작성했습니다.


패턴 1: satisfies - 타입 추론을 유지하면서 검증

// 문제: 상수 객체에 타입을 지정하면 구체적인 타입이 사라짐
type ColorMap = Record<string, string>;

// ❌ 타입 선언: color.red가 string이 됨 (구체적인 값 사라짐)
const colors: ColorMap = {
  red: '#FF0000',
  green: '#00FF00',
};
type RedType = typeof colors.red;  // string (구체적 값 모름)

// ✅ satisfies: 구체적 타입 유지 + 타입 검증
const colors2 = {
  red: '#FF0000',
  green: '#00FF00',
  invalid: 123,  // 에러! number는 string이 아님
} satisfies ColorMap;

type RedType2 = typeof colors2.red;  // "#FF0000" (리터럴 타입!)

패턴 2: Template Literal Types - 문자열 타입 조합

// HTTP 메서드 + 경로 조합으로 API 타입 생성
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Path = '/users' | '/products' | '/orders';

type Endpoint = `${Method} ${Path}`;
// "GET /users" | "GET /products" | "POST /users" | ... (12가지 조합)

// 이벤트 핸들러 이름 자동 생성
type EventName = 'click' | 'focus' | 'change';
type Handler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onChange"

// CSS 클래스 타입 안전성
type Size = 'sm' | 'md' | 'lg';
type Variant = 'primary' | 'secondary';
type ButtonClass = `btn-${Size}` | `btn-${Variant}` | `btn-${Size}-${Variant}`;

function applyClass(cls: ButtonClass) { /* ... */ }
applyClass('btn-sm');           // OK
applyClass('btn-primary');      // OK
applyClass('btn-lg-secondary'); // OK
applyClass('btn-invalid');      // 에러!

패턴 3: Conditional Types + infer

// 함수의 반환 타입 추출
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// (이미 내장 타입이지만 원리 이해)

// Promise를 언래핑하는 타입
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;

// 실용 예시: API 응답 타입에서 data 추출
type ApiResponse<T> = { data: T; status: number; message: string };
type ExtractData<T> = T extends ApiResponse<infer D> ? D : never;

type UserResponse = ApiResponse<{ id: string; name: string }>;
type UserData = ExtractData<UserResponse>;
// { id: string; name: string }

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

패턴 4: 타입 가드 고급 패턴

// 기본 타입 가드
function isString(value: unknown): value is string {
  return typeof value === 'string';
}

// 구체적인 객체 타입 가드
interface User { type: 'user'; id: string; name: string }
interface Admin { type: 'admin'; id: string; permissions: string[] }
type Person = User | Admin;

function isAdmin(person: Person): person is Admin {
  return person.type === 'admin';
}

// 제네릭 타입 가드
function hasProperty<T, K extends PropertyKey>(
  obj: T,
  key: K
): obj is T & Record<K, unknown> {
  return key in (obj as object);
}

// 타입 단언 함수 (assert)
function assertIsString(val: unknown): asserts val is string {
  if (typeof val !== 'string') {
    throw new Error(`Expected string, got ${typeof val}`);
  }
}

function processValue(val: unknown) {
  assertIsString(val);
  // 이후 val은 string으로 추론됨
  console.log(val.toUpperCase());
}

패턴 5: Mapped Types - 타입 변환

// 모든 프로퍼티를 선택적으로
type Partial<T> = { [K in keyof T]?: T[K] };

// 특정 키만 선택적으로 (실용적!)
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

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

// UpdateProduct: id는 필수, 나머지는 선택적
type UpdateProduct = PartialBy<Product, 'name' | 'price' | 'description'>;
// { id: string; name?: string; price?: number; description?: string }

// 읽기 전용 딥 타입
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

// Getter 메서드 자동 생성 타입
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type ProductGetters = Getters<Product>;
// { getId: () => string; getName: () => string; ... }

패턴 6: const Type Parameters (TypeScript 5.0+)

// 이전: 배열이 string[]로 추론됨
function first<T>(arr: T[]): T {
  return arr[0];
}
const result = first(['a', 'b', 'c']);
type R = typeof result;  // string (리터럴 아님)

// TypeScript 5.0: const 파라미터로 리터럴 타입 유지
function firstConst<const T extends readonly unknown[]>(arr: T): T[0] {
  return arr[0];
}
const result2 = firstConst(['a', 'b', 'c']);
type R2 = typeof result2;  // "a" (첫 번째 리터럴!)

// 실용 예시: 설정 객체 타입 안전 팩토리
function createRoutes<const T extends Record<string, string>>(routes: T): T {
  return routes;
}

const routes = createRoutes({
  home: '/home',
  profile: '/profile/:id',
  settings: '/settings',
});

type HomeRoute = typeof routes.home;  // "/home" (리터럴!)

패턴 7: Discriminated Unions + Exhaustive Check

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'rectangle'; width: number; height: number }
  | { kind: 'triangle'; base: number; height: number };

// 모든 케이스를 처리했는지 컴파일 타임 검증
function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rectangle':
      return shape.width * shape.height;
    case 'triangle':
      return (shape.base * shape.height) / 2;
    default:
      // 새 Shape가 추가되면 여기서 컴파일 에러
      const _exhaustive: never = shape;
      throw new Error(`처리되지 않은 shape: ${JSON.stringify(shape)}`);
  }
}

패턴 8: Branded Types - 원시 타입 혼용 방지

// 문제: ID들이 모두 string이라 혼용 가능
function getUser(userId: string) { /* ... */ }
function getOrder(orderId: string) { /* ... */ }

getUser(orderId);  // 타입 에러 없음! 런타임 버그

// 해결: Branded Type
type UserId = string & { readonly _brand: 'UserId' };
type OrderId = string & { readonly _brand: 'OrderId' };

// 생성 함수로만 만들 수 있게 제어
function createUserId(id: string): UserId {
  return id as UserId;
}

function getUser(userId: UserId) { /* ... */ }
function getOrder(orderId: OrderId) { /* ... */ }

const userId = createUserId('user-123');
const orderId = 'order-456' as OrderId;

getUser(userId);    // OK
getUser(orderId);   // 타입 에러! UserId !== OrderId

패턴 9: Template Literal + 키 추출 조합

// API 엔드포인트를 타입으로 관리
const API_ENDPOINTS = {
  'GET /users': { response: { users: User[] } },
  'POST /users': { body: CreateUserDto; response: User },
  'GET /users/:id': { params: { id: string }; response: User },
  'DELETE /users/:id': { params: { id: string }; response: void },
} as const;

type Endpoints = typeof API_ENDPOINTS;
type EndpointKey = keyof Endpoints;

type EndpointResponse<K extends EndpointKey> = 
  Endpoints[K] extends { response: infer R } ? R : never;

// 타입 안전한 API 클라이언트
async function apiCall<K extends EndpointKey>(
  endpoint: K,
  options: Omit<Endpoints[K], 'response'>
): Promise<EndpointResponse<K>> {
  // 구현
}

const users = await apiCall('GET /users', {});  // User[] 반환
const user = await apiCall('GET /users/:id', { params: { id: '123' } });  // User 반환

패턴 10: 타입 레벨 테스트

// 타입이 예상대로 추론되는지 테스트
type Assert<T extends true> = T;
type Equals<X, Y> = X extends Y ? (Y extends X ? true : false) : false;

// 타입 레벨 단언
type Test1 = Assert<Equals<ReturnType<typeof getUser>, User>>;
type Test2 = Assert<Equals<Parameters<typeof createUserId>[0], string>>;

// 실제 CI에서 타입 검증 (tsd 라이브러리)
import { expectType, expectError } from 'tsd';

expectType<string>(someFunction());
expectError(someFunction(123));  // number를 전달하면 에러 예상

마무리

TypeScript 타입 시스템의 목표는 런타임 버그를 컴파일 타임에 잡는 것이다. any를 쓰는 순간 이 목표가 무너진다.

10가지 패턴 중 지금 당장 적용하면 효과 큰 것:

  1. satisfies - 상수 객체 타입 검증
  2. Branded Types - ID 혼용 방지
  3. Discriminated Unions - 상태 모델링
  4. 타입 가드 - 타입 내로잉

나머지는 필요할 때 참조하면 된다. 타입을 완벽하게 만들려 하지 말고, 실제 버그를 잡는 데 집중하라.