6.4 템플릿 리터럴 타입
템플릿 리터럴 타입이란?
TypeScript 4.1에서 도입된 템플릿 리터럴 타입은 JavaScript의 템플릿 리터럴(`문자열 ${변수}`)을 타입 레벨로 끌어올린 기능입니다. 문자열 타입을 결합하거나 변환하여 새로운 문자열 리터럴 타입을 만들 수 있습니다.
type Greeting = `Hello, ${string}!`;
// "Hello, " 로 시작하고 "!" 로 끝나는 모든 문자열
type EventName = `on${'Click' | 'Focus' | 'Blur'}`;
// 'onClick' | 'onFocus' | 'onBlur'
템플릿 리터럴 타입을 사용하면:
- API 경로, 이벤트 이름, CSS 클래스 등 패턴이 있는 문자열을 타입 안전하게 다룰 수 있습니다.
- 유니온 타입을 자동으로 조합하여 모든 경우의 수를 타입으로 표현합니다.
as절과 결합해 매핑 타입의 키를 동적으로 변환합니다.
핵심 개념
1. 기본 문법
// 문자열과 타입 결합
type Prefix = 'get' | 'set' | 'delete';
type Resource = 'User' | 'Post' | 'Comment';
type ApiMethod = `${Prefix}${Resource}`;
// 'getUser' | 'getPost' | 'getComment'
// | 'setUser' | 'setPost' | 'setComment'
// | 'deleteUser' | 'deletePost' | 'deleteComment'
// 구체적인 타입 검사
function callApi(method: ApiMethod): void {
console.log(`API 호출: ${method}`);
}
callApi('getUser'); // 정상
callApi('setPost'); // 정상
// callApi('fetchUser'); // 오류!
2. 내장 문자열 유틸리티 타입
TypeScript는 문자열 조작을 위한 4가지 내장 유틸리티 타입을 제공합니다.
// Uppercase<S>: 모든 문자를 대문자로
type Upper = Uppercase<'hello world'>; // 'HELLO WORLD'
// Lowercase<S>: 모든 문자를 소문자로
type Lower = Lowercase<'Hello World'>; // 'hello world'
// Capitalize<S>: 첫 글자만 대문자로
type Cap = Capitalize<'helloWorld'>; // 'HelloWorld'
// Uncapitalize<S>: 첫 글자만 소문자로
type Uncap = Uncapitalize<'HelloWorld'>; // 'helloWorld'
// 타입 변수와 결합
type EventHandler<T extends string> = `on${Capitalize<T>}`;
type ClickHandler = EventHandler<'click'>; // 'onClick'
type FocusHandler = EventHandler<'focus'>; // 'onFocus'
type BlurHandler = EventHandler<'blur'>; // 'onBlur'
3. 유니온과 결합 — 자동 조합 생성
템플릿 리터럴 타입 안에 유니온 타입이 들어오면, 모든 조합이 자동으로 생성됩니다.
type Color = 'red' | 'green' | 'blue';
type Shade = 'light' | 'dark';
// 6가지 조합이 자동 생성
type ShadedColor = `${Shade}-${Color}`;
// 'light-red' | 'light-green' | 'light-blue'
// | 'dark-red' | 'dark-green' | 'dark-blue'
// CSS 단위 타입
type CSSUnit = 'px' | 'rem' | 'em' | 'vw' | 'vh' | '%';
type CSSValue = `${number}${CSSUnit}`;
// 사용
function setWidth(value: CSSValue): void {
document.body.style.width = value;
}
setWidth('100px'); // 정상
setWidth('2.5rem'); // 정상
// setWidth('100pt'); // 오류!
4. 이벤트 타입 자동화 패턴
// on${EventName} 패턴으로 이벤트 핸들러 타입 자동 생성
type EventName = 'click' | 'focus' | 'blur' | 'change' | 'submit';
type EventHandlerName = `on${Capitalize<EventName>}`;
// 'onClick' | 'onFocus' | 'onBlur' | 'onChange' | 'onSubmit'
// 매핑 타입과 결합
interface DOMEventMap {
click: MouseEvent;
focus: FocusEvent;
blur: FocusEvent;
change: Event;
submit: SubmitEvent;
}
type DOMEventHandlers = {
[K in keyof DOMEventMap as `on${Capitalize<K & string>}`]?: (
event: DOMEventMap[K]
) => void;
};
// {
// onClick?: (event: MouseEvent) => void;
// onFocus?: (event: FocusEvent) => void;
// ...
// }
5. CSS 단위 타입과 API 경로 타입
// CSS 단위
type Length = `${number}${'px' | 'rem' | 'em' | 'vw' | 'vh'}`;
type Percentage = `${number}%`;
type CSSSize = Length | Percentage | 'auto' | 'inherit';
// 방향별 CSS 프로퍼티 자동 생성
type Direction = 'top' | 'right' | 'bottom' | 'left';
type SpacingProp = `${'margin' | 'padding'}-${Direction}`;
// 'margin-top' | 'margin-right' | ... | 'padding-left'
// REST API 경로 타입
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
type APIVersion = 'v1' | 'v2';
type ResourcePath = `/${APIVersion}/${'users' | 'posts' | 'comments'}`;
// '/v1/users' | '/v1/posts' | ... | '/v2/comments'
// 경로 파라미터 패턴
type IDParam = `${string}/:id`;
type ResourceWithId = `/${APIVersion}/${'users' | 'posts'}/:id`;
코드 예제
예제 1: 타입 안전한 이벤트 에미터
type EventMap = Record<string, unknown>;
class TypedEventEmitter<Events extends EventMap> {
private listeners = new Map<keyof Events, Set<Function>>();
on<K extends keyof Events & string>(
event: K,
listener: (data: Events[K]) => void
): this {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(listener);
return this;
}
off<K extends keyof Events & string>(
event: K,
listener: (data: Events[K]) => void
): this {
this.listeners.get(event)?.delete(listener);
return this;
}
emit<K extends keyof Events & string>(event: K, data: Events[K]): void {
this.listeners.get(event)?.forEach(listener => listener(data));
}
once<K extends keyof Events & string>(
event: K,
listener: (data: Events[K]) => void
): this {
const wrapper = (data: Events[K]) => {
listener(data);
this.off(event, wrapper);
};
return this.on(event, wrapper);
}
}
// 사용
interface AppEvents {
userLogin: { userId: number; timestamp: Date };
userLogout: { userId: number };
messageReceived: { from: number; content: string; roomId: string };
errorOccurred: { code: number; message: string };
}
const emitter = new TypedEventEmitter<AppEvents>();
emitter.on('userLogin', ({ userId, timestamp }) => {
console.log(`사용자 ${userId} 로그인: ${timestamp.toLocaleString()}`);
});
emitter.emit('userLogin', { userId: 42, timestamp: new Date() });
// emitter.emit('userLogin', { userId: '42' }); // 오류! userId는 number
예제 2: Redux Action 타입 자동 생성
// 슬라이스 이름과 액션 이름으로 타입 자동 생성
type ActionType<
Slice extends string,
Action extends string
> = `${Slice}/${Action}`;
// 특정 슬라이스의 모든 액션 타입
type UsersActionTypes = ActionType<'users', 'fetch' | 'add' | 'remove' | 'update'>;
// 'users/fetch' | 'users/add' | 'users/remove' | 'users/update'
// 페이로드 매핑과 결합
interface ActionPayloadMap {
'users/fetch': undefined;
'users/add': { name: string; email: string };
'users/remove': { id: number };
'users/update': { id: number; data: Partial<{ name: string; email: string }> };
'posts/fetch': { userId?: number };
'posts/add': { title: string; content: string; userId: number };
}
type ActionOf<T extends keyof ActionPayloadMap> =
ActionPayloadMap[T] extends undefined
? { type: T }
: { type: T; payload: ActionPayloadMap[T] };
// 사용
type FetchUsersAction = ActionOf<'users/fetch'>;
// { type: 'users/fetch' }
type AddUserAction = ActionOf<'users/add'>;
// { type: 'users/add'; payload: { name: string; email: string } }
// 디스패치 함수 타입
function dispatch<T extends keyof ActionPayloadMap>(
action: ActionOf<T>
): void {
console.log('dispatch:', action);
}
dispatch({ type: 'users/add', payload: { name: '홍길동', email: 'hong@test.com' } });
dispatch({ type: 'users/fetch' });
예제 3: i18n 키 타입
// 번역 키 구조
interface Translations {
common: {
save: string;
cancel: string;
delete: string;
confirm: string;
};
user: {
profile: {
title: string;
editButton: string;
};
settings: {
language: string;
theme: string;
};
};
error: {
notFound: string;
unauthorized: string;
serverError: string;
};
}
// 중첩 키를 점(.) 구분 경로로 추출
type DotPath<T, Prefix extends string = ''> = {
[K in keyof T & string]: T[K] extends string
? Prefix extends ''
? K
: `${Prefix}.${K}`
: DotPath<T[K], Prefix extends '' ? K : `${Prefix}.${K}`>;
}[keyof T & string];
type TranslationKey = DotPath<Translations>;
// 'common.save' | 'common.cancel' | 'common.delete' | 'common.confirm'
// | 'user.profile.title' | 'user.profile.editButton'
// | 'user.settings.language' | 'user.settings.theme'
// | 'error.notFound' | 'error.unauthorized' | 'error.serverError'
// 타입 안전 번역 함수
function t(key: TranslationKey): string {
// 실제 구현에서는 키로 번역 딕셔너리를 조회
return key;
}
t('common.save'); // 정상
t('user.profile.title'); // 정상
// t('common.unknown'); // 오류!
// t('user.profile'); // 오류! 리프 노드가 아님
예제 4: API 클라이언트 타입 자동화
// REST API 엔드포인트 타입 자동 생성
type CRUDAction = 'list' | 'get' | 'create' | 'update' | 'delete';
type EndpointName<
Resource extends string,
Action extends CRUDAction
> = `${Lowercase<Resource>}${Capitalize<Action>}`;
type UserEndpoints = EndpointName<'User', CRUDAction>;
// 'userList' | 'userGet' | 'userCreate' | 'userUpdate' | 'userDelete'
// HTTP 메서드 매핑
type HTTPMethodFor<Action extends CRUDAction> =
Action extends 'list' | 'get' ? 'GET' :
Action extends 'create' ? 'POST' :
Action extends 'update' ? 'PUT' :
Action extends 'delete' ? 'DELETE' :
never;
// 타입 안전한 fetch 래퍼
type FetchOptions<Method extends string> = {
method: Method;
headers?: Record<string, string>;
body?: Method extends 'GET' ? never : string;
};
고수 팁
팁 1: 복잡한 문자열 파싱 — 재귀 + infer
// 문자열에서 파라미터 이름 추출
// "/users/:id/posts/:postId" -> 'id' | 'postId'
type ExtractRouteParams<Path extends string> =
Path extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<`/${Rest}`>
: Path extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractRouteParams<'/users/:id/posts/:postId/comments/:commentId'>;
// 'id' | 'postId' | 'commentId'
// 경로 파라미터 객체 타입 생성
type RouteParams<Path extends string> = {
[K in ExtractRouteParams<Path>]: string;
};
type UserPostParams = RouteParams<'/users/:userId/posts/:postId'>;
// { userId: string; postId: string; }
// 사용
function navigate<Path extends string>(
path: Path,
params: RouteParams<Path>
): string {
let result: string = path;
for (const [key, value] of Object.entries(params)) {
result = result.replace(`:${key}`, value);
}
return result;
}
const url = navigate('/users/:userId/posts/:postId', {
userId: '42',
postId: '123',
});
// '/users/42/posts/123'
팁 2: 쿼리 문자열 타입 파싱
// "key1=value1&key2=value2" 형태에서 키 추출
type ParseQueryKey<Q extends string> =
Q extends `${infer Key}=${string}&${infer Rest}`
? Key | ParseQueryKey<Rest>
: Q extends `${infer Key}=${string}`
? Key
: never;
type Keys = ParseQueryKey<'name=john&age=30&role=admin'>;
// 'name' | 'age' | 'role'
팁 3: 성능 주의사항
// 주의: 유니온 크기가 클수록 타입 계산 비용이 급격히 증가
// 좋지 않은 예: 너무 많은 조합 생성
type TooMany =
`${string & ('a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h')}${
string & ('1' | '2' | '3' | '4' | '5' | '6' | '7' | '8')}`;
// 64개 조합 — 컴파일러 부하 증가
// 권장: 필요한 조합만 정의
type Header = 'X-Request-Id' | 'X-Correlation-Id' | 'Authorization' | 'Content-Type';
// 명시적 유니온이 템플릿 리터럴보다 컴파일 성능이 좋음
// 재귀 깊이도 제한됨 (TypeScript 기본값: ~100)
// 깊은 재귀가 필요하면 단계별로 분리
// 허용되는 수준
type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
type TwoDigit = `${Digit}${Digit}`; // 100개 — 허용 범위
팁 4: 런타임에서 타입 정보 활용
// 타입과 런타임을 함께 관리하는 패턴
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const;
type HTTPMethod = typeof HTTP_METHODS[number];
// 런타임 검사 + 타입 가드
function isHTTPMethod(value: string): value is HTTPMethod {
return (HTTP_METHODS as readonly string[]).includes(value);
}
// 이벤트 이름 배열을 타입으로 활용
const WIDGET_EVENTS = ['click', 'hover', 'focus', 'blur'] as const;
type WidgetEvent = typeof WIDGET_EVENTS[number];
type WidgetEventHandler = `on${Capitalize<WidgetEvent>}`;
// 'onClick' | 'onHover' | 'onFocus' | 'onBlur'
const WIDGET_EVENT_HANDLERS: WidgetEventHandler[] = WIDGET_EVENTS.map(
event => `on${event.charAt(0).toUpperCase()}${event.slice(1)}` as WidgetEventHandler
);
정리 표
| 기능 | 문법 예시 | 설명 |
|---|---|---|
| 기본 결합 | `${A}${B}` | 두 타입을 문자열로 결합 |
| 유니온 조합 | `${'a' | 'b'}X` | 유니온의 모든 조합 자동 생성 |
| Uppercase | Uppercase<'hello'> | 모든 문자 대문자 변환 |
| Lowercase | Lowercase<'HELLO'> | 모든 문자 소문자 변환 |
| Capitalize | Capitalize<'hello'> | 첫 글자만 대문자 |
| Uncapitalize | Uncapitalize<'Hello'> | 첫 글자만 소문자 |
| 이벤트 핸들러 | `on${Capitalize<T>}` | 이벤트 이름에서 핸들러 타입 생성 |
| 경로 파라미터 추출 | infer + 재귀 | 문자열에서 패턴 분해 |
| i18n 키 | DotPath<T> 패턴 | 중첩 객체 키를 점 경로로 |
| CSS 값 | `${number}${'px' | 'rem'}` | 숫자 + 단위 문자열 타입 |
| API 경로 | `/${Version}/${Resource}` | REST 경로 타입 안전성 |
다음 장에서는 **재귀 타입(6.5)**을 살펴봅니다. 자기 자신을 참조하는 타입으로 JSON 구조, 파일 시스템 트리, 중첩 메뉴 같은 계층형 데이터를 표현하고, DeepPartial, DeepReadonly 같은 심화 유틸리티 타입을 직접 구현합니다.