
Template Literal Types: 문자열 패턴을 타입으로 잡기
CSS 클래스명을 string으로 받다가 오타 버그가 계속 났는데, Template Literal Types로 문자열 패턴 자체를 타입으로 만들 수 있었다.

CSS 클래스명을 string으로 받다가 오타 버그가 계속 났는데, Template Literal Types로 문자열 패턴 자체를 타입으로 만들 수 있었다.
any를 쓰면 타입스크립트를 쓰는 의미가 없습니다. 제네릭(Generics)을 통해 유연하면서도 타입 안전성(Type Safety)을 모두 챙기는 방법을 정리합니다. infer, keyof 등 고급 기법 포함.

부모에서 전달한 props가 undefined로 나와서 앱이 크래시되는 문제 해결

TypeScript/JavaScript에서 절대 경로 import 설정이 안 될 때의 원인을 '지도와 택시 기사' 비유로 설명합니다. CJS vs ESM 역사적 배경과 모노레포 설정, 팀 컨벤션까지 총정리.

API 요청 상태를 관리할 때 불리언 플래그 여러 개를 쓰시나요? 'impossible state(불가능한 상태)'를 방지하고, if 문 도배를 없애는 Discriminated Unions 패턴.

프로젝트에서 이벤트 시스템을 만들고 있었다. emit('userLoggedIn')처럼 문자열로 이벤트를 발행하는 구조였는데, 문제는 이벤트명이 30개가 넘어가면서 오타가 속출했다는 것. userLogedIn, user-loggedIn, UserLoggedIn... 대소문자 하나, 하이픈 하나 차이로 이벤트가 안 먹히는데, TypeScript는 그냥 string 타입이라 아무런 경고도 안 줬다.
"문자열 패턴 자체를 타입으로 만들 수 있다면?" 이라는 생각이 들었고, 그게 바로 Template Literal Types였다. CSS 클래스명 margin-top-4, API 라우트 /api/users/${id}, 이벤트명 user:logged:in 같은 패턴을 타입 레벨에서 강제할 수 있다는 게 처음엔 마법처럼 느껴졌다.
결론부터 말하면, Template Literal Types는 "문자열의 정규표현식을 타입으로 옮긴 것" 같은 느낌이었다. 런타임 검증이 아니라 컴파일 타임에 문자열 패턴을 잡는다는 게 핵심이다.
Template Literal Types의 기본 문법은 JavaScript 템플릿 리터럴과 똑같이 생겼다.
type EventName = `user:${string}`;
const validEvent: EventName = "user:login"; // ✅
const validEvent2: EventName = "user:logout"; // ✅
const invalidEvent: EventName = "admin:login"; // ❌ Type error
user:로 시작하는 모든 문자열을 타입으로 정의했다. 이게 처음 봤을 때 와닿았던 지점이다. "타입인데 문자열 패턴을 표현하네?"
진짜 위력은 Union 타입과 조합할 때 나타났다.
type Action = "get" | "set" | "delete";
type Entity = "user" | "post" | "comment";
type ApiRoute = `/api/${Entity}/${Action}`;
// 자동으로 이렇게 확장된다:
// "/api/user/get" | "/api/user/set" | "/api/user/delete" |
// "/api/post/get" | "/api/post/set" | "/api/post/delete" |
// "/api/comment/get" | "/api/comment/set" | "/api/comment/delete"
const route: ApiRoute = "/api/user/get"; // ✅
const invalid: ApiRoute = "/api/admin/get"; // ❌
3개 × 3개 = 9개의 타입이 자동으로 생성된다. 이게 내가 이해한 "Union distribution"이다. 타입 조합을 곱셈처럼 처리한다는 게 직관적이었다.
TypeScript는 문자열 조작 유틸리티 타입을 제공한다.
type Shout = Uppercase<"hello">; // "HELLO"
type Whisper = Lowercase<"WORLD">; // "world"
type Proper = Capitalize<"typescript">; // "Typescript"
type Uncap = Uncapitalize<"TypeScript">; // "typeScript"
// Template Literal과 조합
type HttpMethod = "get" | "post" | "put" | "delete";
type MethodName = `on${Capitalize<HttpMethod>}`;
// "onGet" | "onPost" | "onPut" | "onDelete"
이걸 처음 봤을 때 든 생각: "이건 마치 타입 레벨의 .toUpperCase() 같은데?" 맞다. 런타임 함수를 타입 시스템으로 옮긴 것이다.
가장 먼저 적용한 건 이벤트 시스템이었다.
type EventMap = {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"post:created": { postId: string; authorId: string };
"post:deleted": { postId: string };
};
type EventName = keyof EventMap;
class TypedEventEmitter {
emit<K extends EventName>(event: K, data: EventMap[K]) {
// 실제 발행 로직
console.log(`Event: ${event}`, data);
}
on<K extends EventName>(
event: K,
handler: (data: EventMap[K]) => void
) {
// 리스너 등록 로직
}
}
const emitter = new TypedEventEmitter();
emitter.emit("user:login", {
userId: "123",
timestamp: Date.now()
}); // ✅
emitter.emit("user:login", {
userId: "123"
}); // ❌ timestamp 없음
emitter.emit("user:logins", {
userId: "123",
timestamp: Date.now()
}); // ❌ 오타
핵심: 이벤트명과 데이터 구조가 함께 타입 체크된다. 이전엔 emit(event: string, data: any)였는데, 이제는 이벤트명 오타도 잡히고 데이터 구조도 강제된다.
메타포로 이해하자면, 이전엔 "우편함에 아무 편지나 넣을 수 있었다"면, 이제는 "특정 주소 형식의 편지만 받는 우편함"이 된 것이다.
Tailwind CSS처럼 margin-top-4, padding-left-8 같은 클래스명 패턴을 타입으로 만들었다.
type Spacing = 0 | 1 | 2 | 4 | 8 | 16 | 32;
type Side = "top" | "right" | "bottom" | "left";
type Property = "margin" | "padding";
type SpacingClass = `${Property}-${Side}-${Spacing}`;
function addSpacing(element: HTMLElement, className: SpacingClass) {
element.classList.add(className);
}
addSpacing(div, "margin-top-4"); // ✅
addSpacing(div, "margin-top-5"); // ❌ 5는 없음
addSpacing(div, "margin-tp-4"); // ❌ 오타
실제 교훈: Spacing을 number로 하면 안 된다. margin-top-${number}는 무한대의 타입을 만들어서 TypeScript가 성능 경고를 낸다. 제한된 Union으로 해야 한다.
Next.js 프로젝트에서 API 라우트를 타입 안전하게 만들었다.
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type Resource = "users" | "posts" | "comments";
type ApiEndpoint = `/api/${Lowercase<Resource>}/${string}`;
type RouteConfig<M extends HttpMethod> = {
method: M;
endpoint: ApiEndpoint;
};
function defineRoute<M extends HttpMethod>(
config: RouteConfig<M>
) {
return config;
}
const getUserRoute = defineRoute({
method: "GET",
endpoint: "/api/users/123" // ✅
});
const invalidRoute = defineRoute({
method: "GET",
endpoint: "/api/admins/123" // ❌
});
핵심 깨달음: Template Literal Types는 접두사/접미사 검증에 강하다. /api/로 시작하는지, :id를 포함하는지 같은 패턴을 타입 레벨에서 잡는다.
Template Literal Types의 진짜 위력은 Mapped Types와 만날 때 나타났다.
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type User = {
name: string;
age: number;
email: string;
};
type UserGetters = Getters<User>;
// {
// getName: () => string;
// getAge: () => number;
// getEmail: () => string;
// }
name → getName, age → getAge처럼 프로퍼티명을 변환하면서 새 타입을 생성한다. 이건 마치 타입 레벨의 .map() 같다고 느꼈다.
실제 예시: Redux 액션 생성자 자동 생성
type Actions = {
setUser: { userId: string; name: string };
deleteUser: { userId: string };
updateUser: { userId: string; updates: Partial<User> };
};
type ActionCreators = {
[K in keyof Actions]: (payload: Actions[K]) => {
type: K;
payload: Actions[K];
};
};
// 자동으로 이렇게 된다:
// {
// setUser: (payload: { userId: string; name: string }) =>
// { type: "setUser"; payload: { ... } };
// deleteUser: (payload: { userId: string }) =>
// { type: "deleteUser"; payload: { ... } };
// ...
// }
이걸 실수로 해봤다가 TypeScript 서버가 멈췄다.
// ❌ 이러지 마라
type BadIdea = `user-${number}`; // 무한대의 타입
type WorseTdea = `${string}-${string}-${string}`; // 조합 폭발
// ✅ 대신 이렇게
type GoodIdea = `user-${string}`; // 패턴만 검증
type BetterId = `user-${1 | 2 | 3 | 4 | 5}`; // 제한된 Union
경험상 규칙: Union이 100개 넘어가면 TypeScript가 느려진다. 1000개 넘으면 에러가 난다.
Template Literal Types는 컴파일 타임에만 존재한다. API 응답, 사용자 입력 같은 외부 데이터는 런타임 검증이 필요하다.
type ApiRoute = `/api/${string}`;
// 타입은 맞지만 런타임엔 아무 문자열이나 올 수 있다
async function fetchApi(route: ApiRoute) {
// 여전히 런타임 검증 필요
if (!route.startsWith("/api/")) {
throw new Error("Invalid route");
}
// ...
}
이건 마치 "타입은 설계도, 런타임은 실제 건물"이라는 메타포로 이해했다. 설계도가 완벽해도 시공 과정에서 검증이 필요하다.
이런 코드를 짰다가 팀원이 이해 못 했다.
// ❌ 너무 복잡함
type RouteParams<T extends string> =
T extends `${infer Start}:${infer Param}/${infer Rest}`
? { [K in Param]: string } & RouteParams<Rest>
: T extends `${infer Start}:${infer Param}`
? { [K in Param]: string }
: {};
// ✅ 이렇게 단순하게
type RouteWithParams = {
path: string;
params: Record<string, string>;
};
실용주의: 타입이 10줄 넘어가면 무조건 리팩토링하거나 단순화했다. "타입을 읽는 시간 > 버그 방지 시간"이면 과한 것이다.
Template Literal Types는 결국 문자열의 구조적 검증을 타입 레벨로 올린 것이다. 이전엔 string 타입에 런타임 검증을 덧붙였다면, 이제는 타입 자체가 패턴을 표현한다.
${A}-${B}는 A와 B의 모든 조합을 만든다 (곱셈 같다)number는 위험하다user:*, /api/*, on*Event)string이 낫다)결국 Template Literal Types는 "문자열 타입의 정규표현식"이라고 이해했다. 런타임 정규표현식이 문자열을 검증하듯, Template Literal Types는 타입 레벨에서 문자열 패턴을 검증한다. 다만 모든 정규표현식이 좋은 건 아니듯, 모든 Template Literal Types가 유용한 건 아니다. 실용성과 가독성의 균형이 핵심이었다.
I was building an event system for a project. It used string-based event dispatching like emit('userLoggedIn'), and as the event count grew past 30, typos became rampant. userLogedIn, user-loggedIn, UserLoggedIn... One wrong character, one misplaced hyphen, and events silently failed. TypeScript saw them all as string and gave zero warnings.
"What if I could make the string pattern itself a type?" That thought led me to Template Literal Types. CSS classes like margin-top-4, API routes like /api/users/${id}, event names like user:logged:in—all could be enforced at the type level. It felt like magic at first.
Bottom line: Template Literal Types are like "regular expressions lifted into the type system." Not runtime validation, but compile-time pattern enforcement for strings.
Template Literal Types look exactly like JavaScript template literals.
type EventName = `user:${string}`;
const validEvent: EventName = "user:login"; // ✅
const validEvent2: EventName = "user:logout"; // ✅
const invalidEvent: EventName = "admin:login"; // ❌ Type error
This defines all strings starting with user: as a type. That's when it clicked: "A type that expresses a string pattern?"
The real power shows when combined with Union types.
type Action = "get" | "set" | "delete";
type Entity = "user" | "post" | "comment";
type ApiRoute = `/api/${Entity}/${Action}`;
// Automatically expands to:
// "/api/user/get" | "/api/user/set" | "/api/user/delete" |
// "/api/post/get" | "/api/post/set" | "/api/post/delete" |
// "/api/comment/get" | "/api/comment/set" | "/api/comment/delete"
const route: ApiRoute = "/api/user/get"; // ✅
const invalid: ApiRoute = "/api/admin/get"; // ❌
3 × 3 = 9 types auto-generated. This is what I understood as "Union distribution"—it treats type combinations like multiplication.
TypeScript provides string manipulation utility types.
type Shout = Uppercase<"hello">; // "HELLO"
type Whisper = Lowercase<"WORLD">; // "world"
type Proper = Capitalize<"typescript">; // "Typescript"
type Uncap = Uncapitalize<"TypeScript">; // "typeScript"
// Combined with Template Literals
type HttpMethod = "get" | "post" | "put" | "delete";
type MethodName = `on${Capitalize<HttpMethod>}`;
// "onGet" | "onPost" | "onPut" | "onDelete"
First impression: "This is like type-level .toUpperCase()." Exactly. Runtime functions translated into the type system.
First application: the event system that started this whole journey.
type EventMap = {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"post:created": { postId: string; authorId: string };
"post:deleted": { postId: string };
};
type EventName = keyof EventMap;
class TypedEventEmitter {
emit<K extends EventName>(event: K, data: EventMap[K]) {
console.log(`Event: ${event}`, data);
}
on<K extends EventName>(
event: K,
handler: (data: EventMap[K]) => void
) {
// Listener registration logic
}
}
const emitter = new TypedEventEmitter();
emitter.emit("user:login", {
userId: "123",
timestamp: Date.now()
}); // ✅
emitter.emit("user:login", {
userId: "123"
}); // ❌ Missing timestamp
emitter.emit("user:logins", {
userId: "123",
timestamp: Date.now()
}); // ❌ Typo
Key insight: Event names and data structures are type-checked together. Previously emit(event: string, data: any) caught nothing. Now both typos and data shape are enforced.
Metaphor: Previously "a mailbox accepting any letter," now "a mailbox only accepting letters with specific address formats."
Created type-safe class names like margin-top-4, padding-left-8 similar to Tailwind CSS.
type Spacing = 0 | 1 | 2 | 4 | 8 | 16 | 32;
type Side = "top" | "right" | "bottom" | "left";
type Property = "margin" | "padding";
type SpacingClass = `${Property}-${Side}-${Spacing}`;
function addSpacing(element: HTMLElement, className: SpacingClass) {
element.classList.add(className);
}
addSpacing(div, "margin-top-4"); // ✅
addSpacing(div, "margin-top-5"); // ❌ 5 not allowed
addSpacing(div, "margin-tp-4"); // ❌ Typo
Real-world lesson: Don't use number for Spacing. margin-top-${number} creates infinite types and TypeScript warns about performance. Use constrained Unions.
Made Next.js API routes type-safe in a recent project.
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type Resource = "users" | "posts" | "comments";
type ApiEndpoint = `/api/${Lowercase<Resource>}/${string}`;
type RouteConfig<M extends HttpMethod> = {
method: M;
endpoint: ApiEndpoint;
};
function defineRoute<M extends HttpMethod>(
config: RouteConfig<M>
) {
return config;
}
const getUserRoute = defineRoute({
method: "GET",
endpoint: "/api/users/123" // ✅
});
const invalidRoute = defineRoute({
method: "GET",
endpoint: "/api/admins/123" // ❌
});
Core realization: Template Literal Types excel at prefix/suffix validation. They catch whether strings start with /api/, contain :id, etc., at the type level.
Template Literal Types truly shine when combined with Mapped Types.
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type User = {
name: string;
age: number;
email: string;
};
type UserGetters = Getters<User>;
// {
// getName: () => string;
// getAge: () => number;
// getEmail: () => string;
// }
Transforms name → getName, age → getAge, converting property names while creating new types. Felt like a type-level .map().
Real example: Auto-generating Redux action creators
type Actions = {
setUser: { userId: string; name: string };
deleteUser: { userId: string };
updateUser: { userId: string; updates: Partial<User> };
};
type ActionCreators = {
[K in keyof Actions]: (payload: Actions[K]) => {
type: K;
payload: Actions[K];
};
};
// Automatically becomes:
// {
// setUser: (payload: { userId: string; name: string }) =>
// { type: "setUser"; payload: { ... } };
// deleteUser: (payload: { userId: string }) =>
// { type: "deleteUser"; payload: { ... } };
// ...
// }
Accidentally did this once and froze the TypeScript server.
// ❌ Don't do this
type BadIdea = `user-${number}`; // Infinite types
type WorseIdea = `${string}-${string}-${string}`; // Combinatorial explosion
// ✅ Do this instead
type GoodIdea = `user-${string}`; // Only validates pattern
type BetterId = `user-${1 | 2 | 3 | 4 | 5}`; // Constrained Union
Rule of thumb from experience: Unions over 100 slow TypeScript down. Over 1000 causes errors.
Template Literal Types only exist at compile time. External data like API responses and user input need runtime validation.
type ApiRoute = `/api/${string}`;
// Type is correct but runtime can be any string
async function fetchApi(route: ApiRoute) {
// Still need runtime validation
if (!route.startsWith("/api/")) {
throw new Error("Invalid route");
}
// ...
}
Metaphor: "Types are blueprints, runtime is the actual building." Perfect blueprints still need construction-time validation.
Wrote this once and teammates couldn't understand it.
// ❌ Too complex
type RouteParams<T extends string> =
T extends `${infer Start}:${infer Param}/${infer Rest}`
? { [K in Param]: string } & RouteParams<Rest>
: T extends `${infer Start}:${infer Param}`
? { [K in Param]: string }
: {};
// ✅ Keep it simple
type RouteWithParams = {
path: string;
params: Record<string, string>;
};
Pragmatism: Types over 10 lines get refactored or simplified, no exceptions. If "time reading types > time saved from bugs," it's too complex.