본문으로 건너뛰기
Advertisement

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' &#124; 'b'}X`유니온의 모든 조합 자동 생성
UppercaseUppercase<'hello'>모든 문자 대문자 변환
LowercaseLowercase<'HELLO'>모든 문자 소문자 변환
CapitalizeCapitalize<'hello'>첫 글자만 대문자
UncapitalizeUncapitalize<'Hello'>첫 글자만 소문자
이벤트 핸들러`on${Capitalize<T>}`이벤트 이름에서 핸들러 타입 생성
경로 파라미터 추출infer + 재귀문자열에서 패턴 분해
i18n 키DotPath<T> 패턴중첩 객체 키를 점 경로로
CSS 값`${number}${'px' &#124; 'rem'}`숫자 + 단위 문자열 타입
API 경로`/${Version}/${Resource}`REST 경로 타입 안전성

다음 장에서는 **재귀 타입(6.5)**을 살펴봅니다. 자기 자신을 참조하는 타입으로 JSON 구조, 파일 시스템 트리, 중첩 메뉴 같은 계층형 데이터를 표현하고, DeepPartial, DeepReadonly 같은 심화 유틸리티 타입을 직접 구현합니다.

Advertisement