TypeScript Template Literal Types 완벽 가이드 - 실전 활용법부터 고급 패턴까지
TypeScript 4.1에서 도입된 Template Literal Types는 문자열 기반 타입을 다루는 방식을 완전히 바꿔놓았어요. 런타임에서만 가능했던 문자열 조작을 타입 레벨에서 구현할 수 있게 되면서, API 라우팅, 이벤트 시스템, CSS 클래스명 등 실무에서 문자열을 다루는 모든 영역에서 타입 안정성을 대폭 향상시킬 수 있게 되었죠. 이 글에서는 Template Literal Types의 기본 개념부터 실전에서 바로 적용할 수 있는 고급 패턴까지, 5년 이상의 실무 경험을 바탕으로 체계적으로 정리해드릴게요. 이 가이드를 마스터하면 타입 시스템을 활용한 컴파일 타임 검증으로 런타임 에러를 사전에 방지하고, 더 안전하고 유지보수하기 쉬운 코드를 작성할 수 있을 거예요.
Template Literal Types 기본 개념과 문법
Template Literal Types는 JavaScript의 템플릿 리터럴 문법을 타입 시스템에 그대로 적용한 기능이에요. 백틱()과${}` 구문을 사용해서 타입을 조합할 수 있죠.
// 기본 문법 - 문자열 리터럴 타입 조합
type World = "world";
type Greeting = `hello ${World}`; // "hello world"
// 유니온 타입과 함께 사용하면 모든 조합이 생성됨
type Color = "red" | "blue" | "green";
type Quantity = "one" | "two";
type ColoredQuantity = `${Quantity}-${Color}`;
// "one-red" | "one-blue" | "one-green" | "two-red" | "two-blue" | "two-green"
// 실무 예제: HTTP 메서드와 경로 조합
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type ApiVersion = "v1" | "v2";
type Endpoint = "users" | "posts" | "comments";
type ApiRoute = `/${ApiVersion}/${Endpoint}`;
// "/v1/users" | "/v1/posts" | "/v1/comments" | "/v2/users" | ...
// 타입 안정성이 보장되는 API 호출 함수
function fetchApi(method: HttpMethod, route: ApiRoute) {
return fetch(`https://api.example.com${route}`, { method });
}
// ✅ 올바른 사용
fetchApi("GET", "/v1/users");
// ❌ 컴파일 에러 - 존재하지 않는 경로
// fetchApi("GET", "/v1/products");
Template Literal Types의 핵심은 타입 조합의 자동화에 있어요. 유니온 타입과 결합하면 가능한 모든 조합을 자동으로 생성하기 때문에, 수동으로 모든 경우의 수를 나열할 필요가 없죠. 이는 특히 API 엔드포인트나 이벤트 이름처럼 패턴이 명확한 문자열을 다룰 때 엄청난 생산성 향상을 가져와요.
Intrinsic String Manipulation Types 활용하기
TypeScript는 Template Literal Types와 함께 사용할 수 있는 내장 유틸리티 타입 4가지를 제공해요. 이들은 문자열의 대소문자를 변환하는 작업을 타입 레벨에서 수행할 수 있게 해줘요.
// Uppercase<T>: 모든 문자를 대문자로
type UppercaseGreeting = Uppercase<"hello world">; // "HELLO WORLD"
// Lowercase<T>: 모든 문자를 소문자로
type LowercaseGreeting = Lowercase<"HELLO WORLD">; // "hello world"
// Capitalize<T>: 첫 글자만 대문자로
type CapitalizedGreeting = Capitalize<"hello world">; // "Hello world"
// Uncapitalize<T>: 첫 글자만 소문자로
type UncapitalizedGreeting = Uncapitalize<"Hello World">; // "hello World"
// 실무 예제 1: CSS BEM 명명 규칙 타입
type Block = "button" | "input" | "card";
type Element = "icon" | "label" | "content";
type Modifier = "primary" | "disabled" | "large";
// BEM 클래스명: block__element--modifier
type BemClass = `${Block}__${Element}--${Modifier}`;
// 실무 예제 2: Redux Action 타입 생성
type Entity = "user" | "post" | "comment";
type CrudAction = "create" | "read" | "update" | "delete";
// CRUD_ACTION_ENTITY 형식으로 자동 생성
type ReduxAction = `${Uppercase<CrudAction>}_${Uppercase<Entity>}`;
// "CREATE_USER" | "READ_USER" | "UPDATE_USER" | "DELETE_USER" | ...
// 액션 생성자와 함께 타입 안정성 확보
const createAction = <T extends ReduxAction>(type: T) => ({
type,
payload: {} as any
});
// ✅ 올바른 사용
const action1 = createAction("CREATE_USER");
// ❌ 컴파일 에러
// const action2 = createAction("create_user"); // 소문자는 불가
// 실무 예제 3: 환경 변수 이름 규칙
type EnvPrefix = "REACT_APP" | "VITE" | "NEXT_PUBLIC";
type EnvKey = "api_url" | "api_key" | "debug_mode";
type EnvVariable = `${EnvPrefix}_${Uppercase<EnvKey>}`;
// "REACT_APP_API_URL" | "VITE_API_URL" | ...
// 타입 안전한 환경 변수 접근
function getEnv(key: EnvVariable): string | undefined {
return process.env[key];
}
const apiUrl = getEnv("REACT_APP_API_URL"); // ✅
// getEnv("REACT_APP_api_url"); // ❌ 컴파일 에러
이러한 내장 유틸리티 타입들은 코드 컨벤션을 타입 레벨에서 강제할 수 있게 해줘요. 예를 들어, 팀에서 Redux 액션은 항상 대문자와 언더스코어로 작성한다는 규칙이 있다면, Uppercase를 사용해서 컴파일 타임에 이를 검증할 수 있죠.
이벤트 시스템 타입 정의 패턴
실무에서 Template Literal Types가 가장 빛을 발하는 영역 중 하나가 바로 이벤트 시스템이에요. 타입 안전한 이벤트 핸들러를 구현할 수 있죠.
// 이벤트 이름 패턴 정의
type DomainEvent = "user" | "order" | "product";
type EventAction = "created" | "updated" | "deleted";
type EventName = `${DomainEvent}:${EventAction}`;
// 이벤트별 페이로드 타입 매핑
interface EventPayloadMap {
"user:created": { id: string; email: string; name: string };
"user:updated": { id: string; changes: Partial<{ email: string; name: string }> };
"user:deleted": { id: string };
"order:created": { id: string; userId: string; total: number };
"order:updated": { id: string; status: string };
"order:deleted": { id: string };
"product:created": { id: string; name: string; price: number };
"product:updated": { id: string; changes: Partial<{ name: string; price: number }> };
"product:deleted": { id: string };
}
// 타입 안전한 이벤트 이미터 구현
class TypedEventEmitter {
private handlers = new Map<string, Set<Function>>();
// 이벤트 리스너 등록 - 타입 안정성 확보
on<E extends EventName>(
event: E,
handler: (payload: EventPayloadMap[E]) => void
): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
}
// 이벤트 발생 - 페이로드 타입 자동 추론
emit<E extends EventName>(
event: E,
payload: EventPayloadMap[E]
): void {
const handlers = this.handlers.get(event);
if (handlers) {
handlers.forEach(handler => handler(payload));
}
}
// 리스너 제거
off<E extends EventName>(
event: E,
handler: (payload: EventPayloadMap[E]) => void
): void {
const handlers = this.handlers.get(event);
if (handlers) {
handlers.delete(handler);
}
}
}
// 실제 사용 예제
const emitter = new TypedEventEmitter();
// ✅ 올바른 사용 - 타입이 자동으로 추론됨
emitter.on("user:created", (payload) => {
console.log(payload.email); // payload는 { id, email, name } 타입
console.log(payload.name);
});
emitter.on("order:created", (payload) => {
console.log(payload.total); // payload는 { id, userId, total } 타입
});
// ✅ emit 시에도 올바른 페이로드 타입 요구
emitter.emit("user:created", {
id: "1",
email: "user@example.com",
name: "John Doe"
});
// ❌ 컴파일 에러 - 잘못된 페이로드
// emitter.emit("user:created", { id: "1" }); // email, name 누락
// ❌ 컴파일 에러 - 존재하지 않는 이벤트
// emitter.on("user:archived", (payload) => {});
이 패턴의 장점은 이벤트 이름과 페이로드 타입이 자동으로 연결된다는 거예요. 새로운 이벤트를 추가할 때 EventPayloadMap만 업데이트하면 모든 곳에서 타입 체크가 작동하죠. 런타임에 발생할 수 있는 "이벤트 이름 오타" 같은 실수를 컴파일 타임에 잡아낼 수 있어요.
REST API 라우팅 타입 시스템 구축
백엔드 API와 통신하는 프론트엔드 애플리케이션에서 Template Literal Types로 타입 안전한 API 클라이언트를 만들 수 있어요.
// API 엔드포인트 구조 정의
type ApiVersion = "v1" | "v2";
type ResourceType = "users" | "posts" | "comments" | "products";
// 리소스별 가능한 작업 정의
type ResourceActions = {
users: "list" | "detail" | "create" | "update" | "delete" | "profile";
posts: "list" | "detail" | "create" | "update" | "delete" | "publish";
comments: "list" | "detail" | "create" | "delete";
products: "list" | "detail" | "create" | "update" | "delete" | "inventory";
};
// 엔드포인트 경로 생성 (ID가 필요한 경우와 아닌 경우 구분)
type EndpointWithId<R extends ResourceType> = `/${ApiVersion}/${R}/:id`;
type EndpointWithoutId<R extends ResourceType> = `/${ApiVersion}/${R}`;
type EndpointWithAction<R extends ResourceType, A extends string> =
`/${ApiVersion}/${R}/:id/${A}`;
// HTTP 메서드와 엔드포인트 조합
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
// 요청/응답 타입 매핑
interface ApiEndpoints {
"GET /v1/users": {
params: { page?: number; limit?: number };
response: { id: string; email: string; name: string }[];
};
"GET /v1/users/:id": {
params: { id: string };
response: { id: string; email: string; name: string; createdAt: string };
};
"POST /v1/users": {
body: { email: string; name: string; password: string };
response: { id: string; email: string; name: string };
};
"PUT /v1/users/:id": {
params: { id: string };
body: { email?: string; name?: string };
response: { id: string; email: string; name: string };
};
"DELETE /v1/users/:id": {
params: { id: string };
response: { success: boolean };
};
"GET /v1/posts": {
params: { authorId?: string; published?: boolean };
response: { id: string; title: string; content: string }[];
};
}
// 타입 안전한 API 클라이언트 구현
class TypeSafeApiClient {
constructor(private baseURL: string) {}
// GET 요청
async get<K extends Extract<keyof ApiEndpoints, `GET ${string}`>>(
endpoint: K extends `GET ${infer Path}` ? Path : never,
params?: ApiEndpoints[K] extends { params: infer P } ? P : never
): Promise<ApiEndpoints[K]["response"]> {
const url = this.buildUrl(endpoint, params);
const response = await fetch(`${this.baseURL}${url}`);
return response.json();
}
// POST 요청
async post<K extends Extract<keyof ApiEndpoints, `POST ${string}`>>(
endpoint: K extends `POST ${infer Path}` ? Path : never,
body: ApiEndpoints[K] extends { body: infer B } ? B : never
): Promise<ApiEndpoints[K]["response"]> {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
return response.json();
}
// PUT 요청
async put<K extends Extract<keyof ApiEndpoints, `PUT ${string}`>>(
endpoint: K extends `PUT ${infer Path}` ? Path : never,
params: ApiEndpoints[K] extends { params: infer P } ? P : never,
body: ApiEndpoints[K] extends { body: infer B } ? B : never
): Promise<ApiEndpoints[K]["response"]> {
const url = this.buildUrl(endpoint, params);
const response = await fetch(`${this.baseURL}${url}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
return response.json();
}
private buildUrl(endpoint: string, params?: any): string {
let url = endpoint;
if (params) {
// :id 같은 경로 파라미터 치환
Object.keys(params).forEach(key => {
url = url.replace(`:${key}`, params[key]);
});
}
return url;
}
}
// 실제 사용 예제
const api = new TypeSafeApiClient("https://api.example.com");
async function example() {
// ✅ 타입 안전한 API 호출
const users = await api.get("/v1/users", { page: 1, limit: 10 });
// users 타입: { id: string; email: string; name: string }[]
const user = await api.get("/v1/users/:id", { id: "123" });
// user 타입: { id: string; email: string; name: string; createdAt: string }
const newUser = await api.post("/v1/users", {
email: "new@example.com",
name: "New User",
password: "secret123"
});
// newUser 타입: { id: string; email: string; name: string }
// ❌ 컴파일 에러 - 잘못된 엔드포인트
// await api.get("/v1/invalid");
// ❌ 컴파일 에러 - 필수 필드 누락
// await api.post("/v1/users", { email: "test@example.com" });
// ❌ 컴파일 에러 - 잘못된 파라미터 타입
// await api.get("/v1/users", { page: "invalid" });
}
이 패턴의 핵심은 API 스펙을 타입으로 정의하면 모든 호출이 자동으로 검증된다는 점이에요. 백엔드 API가 변경되면 ApiEndpoints 인터페이스만 업데이트하면 되고, 영향받는 모든 코드에서 컴파일 에러가 발생해서 수정이 필요한 부분을 즉시 알 수 있죠.
조건부 타입과 Template Literal Types 결합
Template Literal Types는 조건부 타입, Mapped Types와 결합하면 훨씬 강력해져요. 고급 타입 조작 패턴을 살펴볼게요.
// 문자열에서 특정 패턴 추출하기
type ExtractRouteParams<T extends string> =
T extends `${infer Start}/:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<`/${Rest}`>
: T extends `${infer Start}/:${infer Param}`
? Param
: never;
// 예제: 라우트에서 파라미터 이름 추출
type Route1 = "/users/:userId/posts/:postId";
type Params1 = ExtractRouteParams<Route1>; // "userId" | "postId"
type Route2 = "/products/:productId";
type Params2 = ExtractRouteParams<Route2>; // "productId"
// 라우트 파라미터를 객체 타입으로 변환
type RouteParams<T extends string> = {
[K in ExtractRouteParams<T>]: string;
};
type UserPostParams = RouteParams<"/users/:userId/posts/:postId">;
// { userId: string; postId: string }
// 실전 활용: 타입 안전한 라우터 구현
class TypedRouter {
// 라우트 등록 시 핸들러의 파라미터 타입이 자동 추론됨
route<T extends string>(
path: T,
handler: (params: RouteParams<T>) => void
) {
// 라우터 로직...
return this;
}
}
const router = new TypedRouter();
// ✅ params 타입이 자동으로 { userId: string; postId: string }
router.route("/users/:userId/posts/:postId", (params) => {
console.log(params.userId);
console.log(params.postId);
// ❌ 컴파일 에러 - 존재하지 않는 파라미터
// console.log(params.commentId);
});
// Getter/Setter 타입 생성
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type Setters<T> = {
[K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
};
// 실무 예제: 자동 getter/setter 생성
interface User {
name: string;
email: string;
age: number;
}
type UserGetters = Getters<User>;
// {
// getName: () => string;
// getEmail: () => string;
// getAge: () => number;
// }
type UserSetters = Setters<User>;
// {
// setName: (value: string) => void;
// setEmail: (value: string) => void;
// setAge: (value: number) => void;
// }
type UserAccessors = UserGetters & UserSetters;
// 클래스 구현에 활용
class UserModel implements UserAccessors {
constructor(
private name: string,
private email: string,
private age: number
) {}
getName = () => this.name;
getEmail = () => this.email;
getAge = () => this.age;
setName = (value: string) => { this.name = value; };
setEmail = (value: string) => { this.email = value; };
setAge = (value: number) => { this.age = value; };
}
// CSS-in-JS 스타일 속성 타입 생성
type CSSProperties = {
color: string;
fontSize: number;
padding: number;
margin: number;
};
type CSSWithHover<T> = T & {
[K in keyof T as `&:hover ${string & K}`]?: T[K];
};
type StyledProps = CSSWithHover<CSSProperties>;
// {
// color: string;
// fontSize: number;
// "&:hover color"?: string;
// "&:hover fontSize"?: number;
// ...
// }
조건부 타입과의 결합은 문자열 파싱을 타입 레벨에서 수행할 수 있게 해줘요. infer 키워드를 사용해서 문자열의 특정 부분을 추출하고, 재귀적으로 처리해서 복잡한 패턴도 다룰 수 있죠. 이는 라우팅 시스템처럼 URL 경로를 파싱해야 하는 경우에 특히 유용해요.
흔한 실수와 주의사항
Template Literal Types를 사용할 때 주의해야 할 포인트들을 정리해볼게요.
실수 1: 과도한 유니온 타입 조합으로 인한 성능 문제
// ❌ 나쁜 예 - 너무 많은 조합 생성 (10 × 10 × 10 = 1000개)
type BadExample = `${
"a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j"
}-${
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10"
}-${
"x" | "y" | "z" | "w" | "v" | "u" | "t" | "s" | "r" | "q"
}`;
// 컴파일 속도 저하, IDE 성능 저하 유발
// ✅ 좋은 예 - 필요한 조합만 명시적으로 정의
type GoodExample =
| `a-1-x`
| `b-2-y`
| `c-3-z`;
// 또는 타입을 분리해서 필요한 경우에만 조합
type Prefix = "user" | "admin" | "guest";
type Action = "create" | "read" | "update" | "delete";
// 12개 조합은 괜찮음
type Permission = `${Prefix}:${Action}`;
// 더 많은 조합이 필요하다면 제네릭으로 분리
type CreatePermission<P extends Prefix, A extends Action> = `${P}:${A}`;
type UserPermission = CreatePermission<"user", Action>; // 4개만 생성
왜 이게 중요한가요? TypeScript 컴파일러는 모든 가능한 조합을 메모리에 보관해요. 조합이 너무 많으면 컴파일 시간이 급격히 증가하고, IDE에서 자동완성이 느려지는 문제가 발생할 수 있어요. 실무에서는 100개 미만의 조합을 유지하는 것이 좋아요.
실수 2: 타입 추론 실패를 고려하지 않은 설계
// ❌ 나쁜 예 - 문자열 리터럴이 아닌 string 타입 사용
function badCreateEvent(name: string, type: string) {
return `${name}:${type}`; // 반환 타입: string
}
const event1 = badCreateEvent("user", "created");
// event1 타입: string (구체적인 정보 손실)
// ✅ 좋은 예 - const assertion이나 제네릭으로 타입 보존
function goodCreateEvent<N extends string, T extends string>(
name: N,
type: T
): `${N}:${T}` {
return `${name}:${type}`;
}
const event2 = goodCreateEvent("user", "created");
// event2 타입: "user:created" (정확한 타입 유지)
// 또는 const assertion 활용
const event3 = `${"user"}:${"created"}` as const;
// event3 타입: "user:created"
// 실무 팁: 함수 파라미터에도 제약 추가
type EventName = "user" | "order" | "product";
type EventType = "created" | "updated" | "deleted";
function strictCreateEvent<
N extends EventName,
T extends EventType
>(name: N, type: T): `${N}:${T}` {
return `${name}:${type}`;
}
// ✅ 타입 체크 통과
strictCreateEvent("user", "created");
// ❌ 컴파일 에러
// strictCreateEvent("unknown", "created");
핵심 포인트: Template Literal Types의 장점을 살리려면 입력 타입도 구체적이어야 해요. string 타입을 사용하면 결과도 string이 되어 타입 정보가 손실돼요. 제네릭을 활용해서 구체적인 타입을 보존하세요.
실수 3: 타입 가드 없이 런타임에서 사용
type ValidEventName = `user:${"created" | "updated" | "deleted"}`;
// ❌ 나쁜 예 - 런타임에서 검증 없이 사용
function badHandleEvent(eventName: string) {
// eventName이 ValidEventName인지 보장할 수 없음
processEvent(eventName as ValidEventName); // 위험한 타입 단언
}
// ✅ 좋은 예 - 타입 가드로 런타임 검증
function isValidEventName(name: string): name is ValidEventName {
const validEvents: ValidEventName[] = [
"user:created",
"user:updated",
"user:deleted"
];
return validEvents.includes(name as ValidEventName);
}
function goodHandleEvent(eventName: string) {
if (isValidEventName(eventName)) {
// 이제 eventName은 ValidEventName 타입
processEvent(eventName);
} else {
throw new Error(`Invalid event name: ${eventName}`);
}
}
// 더 좋은 방법: Zod 같은 런타임 검증 라이브러리 활용
import { z } from "zod";
const eventNameSchema = z.enum(["user:created", "user:updated", "user:deleted"]);
function bestHandleEvent(eventName: string) {
const parsed = eventNameSchema.safeParse(eventName);
if (parsed.success) {
processEvent(parsed.data); // 타입 안전성 보장
} else {
throw new Error(`Invalid event name: ${eventName}`);
}
}
function processEvent(eventName: ValidEventName) {
console.log(`Processing: ${eventName}`);
}
중요한 개념: Template Literal Types는 컴파일 타임 검증이에요. 런타임에서 외부 입력(API 응답, 사용자 입력 등)을 받을 때는 반드시 별도의 검증 로직이 필요해요. 타입 단언(as)은 최대한 피하고, 타입 가드나 검증 라이브러리를 사용하세요.
성능 최적화와 유지보수 팁
실무에서 Template Literal Types를 장기적으로 유지보수하기 위한 베스트 프랙티스예요.
// 팁 1: 타입을 모듈화하고 재사용 가능하게 구성
// types/events.ts
export type EventDomain = "user" | "order" | "product";
export type EventAction = "created" | "updated" | "deleted";
export type DomainEvent = `${EventDomain}:${EventAction}`;
// types/api.ts
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
export type ApiVersion = "v1" | "v2";
// 다른 파일에서 import해서 사용
import type { DomainEvent } from "./types/events";
// 팁 2: 복잡한 타입은 주석으로 문서화
/**
* API 엔드포인트 타입
* @example "GET /v1/users" | "POST /v1/users" | "PUT /v1/users/:id"
*
* HTTP 메서드와 경로를 조합한 타입으로, API 클라이언트에서
* 타입 안전한 요청을 보낼 때 사용합니다.
*/
type ApiEndpoint = `${HttpMethod} /${ApiVersion}/${string}`;
// 팁 3: 타입 헬퍼 유틸리티 작성
// 자주 사용되는 패턴을 헬퍼 타입으로 추출
type Prefix<T extends string, P extends string> = `${P}${T}`;
type Suffix<T extends string, S extends string> = `${T}${S}`;
type Wrap<T extends string, W extends string> = `${W}${T}${W}`;
// 사용 예
type PrefixedEvent = Prefix<"created", "on">; // "oncreated"
type EventHandler = Suffix<"user", "Handler">; // "userHandler"
type QuotedString = Wrap<"hello", '"'>; // '"hello"'
// 팁 4: 점진적 타입 도입 전략
// 1단계: 기존 string 타입을 유지하면서 타입 alias만 도입
type LegacyEventName = string;
type NewEventName = `${EventDomain}:${EventAction}`;
// 2단계: 유니온으로 양쪽 모두 허용 (마이그레이션 기간)
type EventName = LegacyEventName | NewEventName;
// 3단계: 완전히 새 타입으로 전환
// type EventName = NewEventName;
// 팁 5: 타입 테스트 작성
// 타입이 예상대로 동작하는지 검증하는 유틸리티
type Expect<T extends true> = T;
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2)
? true
: false;
// 타입 테스트 예제
type TestCases = [
Expect<Equal<DomainEvent,
"user:created" | "user:updated" | "user:deleted" |
"order:created" | "order:updated" | "order:deleted" |
"product:created" | "product:updated" | "product:deleted"
>>,
Expect<Equal<ExtractRouteParams<"/users/:id">, "id">>,
Expect<Equal<ExtractRouteParams<"/users/:userId/posts/:postId">, "userId" | "postId">>
];
// 팁 6: 브랜드 타입으로 타입 안전성 강화
type BrandedString<T extends string, Brand> = T & { __brand: Brand };
type UserId = BrandedString<string, "UserId">;
type OrderId = BrandedString<string, "OrderId">;
function getUserById(id: UserId) {
// UserId만 받을 수 있음
return { id, name: "User" };
}
// ❌ 컴파일 에러 - 일반 string은 불가
// getUserById("123");
// ✅ 명시적으로 UserId로 변환해야 함
const userId = "123" as UserId;
getUserById(userId);
유지보수 관점에서의 핵심: Template Literal Types로 생성된 복잡한 타입은 디버깅이 어려울 수 있어요. 타입을 작은 단위로 분리하고, 명확한 이름을 붙이고, 주석을 달아서 다른 개발자들이 이해하기 쉽게 만드세요. 또한 타입 테스트를 작성하면 리팩토링 시 타입이 깨지는 것을 방지할 수 있어요.
결론
TypeScript Template Literal Types는 단순히 문자열 타입을 조합하는 기능을 넘어서, 타입 시스템을 활용한 설계 패턴을 가능하게 만들어요. 이 글에서 다룬 핵심 내용을 정리하면:
- 기본 활용: 문자열 리터럴 타입 조합과 Intrinsic Types(Uppercase, Lowercase 등)를 활용한 타입 변환
- 이벤트 시스템: 이벤트 이름과 페이로드를 타입 레벨에서 연결해서 런타임 에러 방지
- API 타입화: REST API 엔드포인트를 타입으로 정의해서 타입 안전한 HTTP 클라이언트 구현
- 고급 패턴: 조건부 타입, Mapped Types와 결합해서 동적 타입 생성 및 문자열 파싱
- 실무 주의사항: 과도한 조합 방지, 타입 추론 보존, 런타임 검증 병행
실무에서 적용할 때는 점진적으로 도입하는 것을 추천해요. 먼저 가장 에러가 많이 발생하는 부분(API 호출, 이벤트 핸들링 등)부터 Template Literal Types를 적용하고, 팀원들이 익숙해지면 범위를 확대하세요. 컴파일 시간과 IDE 성능에 주의하면서, 타입 안전성과 개발 경험의 균형을 찾는 것이 중요해요.
추가로 학습하면 좋은 관련 주제:
- Conditional Types 심화 - Template Literal Types와 함께 사용하면 더 강력한 타입 조작이 가능해요
- Zod와 런타임 검증 - 타입스크립트 타입과 런타임 검증을 통합해서 end-to-end 타입 안전성 확보
- Type-Level Programming - 타입을 함수처럼 다루는 고급 패턴으로 복잡한 비즈니스 로직을 타입으로 표현
Template Literal Types를 마스터하면 타입스크립트의 진정한 힘을 느낄 수 있을 거예요. 코드를 작성하기 전에 타입 시스템이 많은 버그를 잡아주고, 리팩토링도 훨씬 안전하게 할 수 있죠. 오늘 배운 내용을 실제 프로젝트에 적용해보면서 여러분만의 타입 패턴을 만들어가세요!
'TypeScript' 카테고리의 다른 글
| TypeScript Mapped Types 완벽 가이드 - 타입 변환 마스터하기 (0) | 2026.03.12 |
|---|