이 글은 누구를 위한 것인가
- 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가지 패턴 중 지금 당장 적용하면 효과 큰 것:
- satisfies - 상수 객체 타입 검증
- Branded Types - ID 혼용 방지
- Discriminated Unions - 상태 모델링
- 타입 가드 - 타입 내로잉
나머지는 필요할 때 참조하면 된다. 타입을 완벽하게 만들려 하지 말고, 실제 버그를 잡는 데 집중하라.