템플릿 리터럴 타입으로 문자열까지 정적으로 검증하기
문자열도 타입 레벨에서 패턴을 검사할 수 있다
TypeScript 4.1부터 문자열 리터럴 타입을 템플릿 리터럴처럼 조합할 수 있게 됐습니다. 자바스크립트의 템플릿 리터럴 문법을 타입 자리에 그대로 씁니다.
type Direction = "left" | "right" | "top" | "bottom";
type Margin = `margin-${Direction}`;
// "margin-left" | "margin-right" | "margin-top" | "margin-bottom"
function setMargin(property: Margin, value: string) {
// ...
}
setMargin("margin-left", "10px"); // OK
setMargin("margin-diagonal", "1px"); // 에러: "margin-diagonal"은 Margin에 없다Margin은 Direction의 각 멤버 앞에 "margin-"을 붙인 네 개의 문자열 리터럴 유니온으로 확장됩니다. CSS 속성 이름처럼 정해진 패턴을 따라야 하는 문자열 API에서, 오타를 컴파일 타임에 잡아낼 수 있다는 게 핵심 가치입니다.
실전 사례: 이벤트 이름 강제하기
앞에서 이벤트 맵 예시를 다뤘는데, 템플릿 리터럴 타입을 더하면 "on 접두사가 붙은 핸들러 이름"까지 자동으로 만들어낼 수 있습니다.
interface EventMap {
click: MouseEvent;
focus: FocusEvent;
}
type EventHandlers = {
[K in keyof EventMap as `on${Capitalize<string & K>}`]: (event: EventMap[K]) => void;
};
// { onClick: (event: MouseEvent) => void; onFocus: (event: FocusEvent) => void }Capitalize<string & K>는 TypeScript가 기본 제공하는 문자열 조작 타입입니다(Capitalize, Uncapitalize, Uppercase, Lowercase 네 가지가 있습니다). string & K는 K가 keyof EventMap에서 온 symbol이나 number 키일 가능성을 배제하고 문자열로 좁히기 위한 관용구입니다. 이 코드는 이전 챕터의 매핑된 타입과 템플릿 리터럴 타입을 결합한 예시로, React나 Vue 같은 프레임워크가 onClick, onFocus 같은 props 타입을 자동으로 생성할 때 실제로 쓰는 방식입니다.
문자열 파싱까지 타입 레벨에서: 라우트 파라미터 추출
템플릿 리터럴 타입은 infer와 결합하면 문자열을 "파싱"하는 것처럼 동작합니다.
type ExtractParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<Rest>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"이 타입은 :userId, :postId처럼 콜론이 붙은 구간을 재귀적으로 찾아 파라미터 이름만 뽑아냅니다. 첫 번째 조건은 "콜론 뒤에 파라미터가 있고, 그 뒤에 슬래시로 이어지는 나머지 경로가 더 있다"는 경우를 처리하며, 자기 자신(ExtractParams<Rest>)을 재귀 호출해 나머지 경로도 계속 파싱합니다. 이런 패턴은 Next.js나 React Router 같은 라이브러리가 문자열 경로 하나만으로 params.userId, params.postId에 대한 타입 검사를 제공하는 원리이기도 합니다.
다만 솔직히 말하면, 이 정도 깊이의 재귀적 템플릿 리터럴 타입을 직접 작성할 일은 애플리케이션 개발자에게는 거의 없습니다. 대부분은 이미 만들어진 라이브러리(Next.js, tRPC, Zod 등)가 이런 타입을 대신 계산해줍니다. 여기서 원리를 보여드린 이유는, 여러분이 쓰는 라이브러리의 타입 정의 파일(.d.ts)을 열었을 때 이런 코드를 마주쳤을 때 "이게 뭘 하는 코드인지" 알아볼 수 있게 하기 위해서입니다.
얼마나 깊이 써야 하는가
템플릿 리터럴 타입은 강력하지만 재귀가 깊어질수록 컴파일 속도에 영향을 줍니다. 타입 레벨 재귀는 TypeScript가 정한 상한(기본적으로 대략 깊이 1000회 근처, 버전에 따라 다름)에 도달하면 "Type instantiation is excessively deep" 에러를 냅니다. 문자열 파싱이나 검증 로직이 정말 복잡하다면, 타입 레벨에서 전부 해결하려 하지 말고 런타임 검증(예: Zod 같은 스키마 라이브러리)과 역할을 나누는 편이 유지보수 측면에서 더 낫습니다. 타입은 "컴파일 타임에 값싸게 확인할 수 있는 것"을 잡아내는 도구이지, 파서를 대체하는 도구는 아닙니다.