TypeScript Template Literal Types를 예제로 배우기

1. 개요

Template literal types를 사용하면 TypeScript의 타입 시스템에서 직접 백틱 문법을 사용할 수 있습니다. 덕분에 컴파일 시점에 문자열 타입을 결합하고, 변환하고, 분석할 수 있습니다. 이 글에서는 각 기능을 구체적인 예제를 통해 다룹니다: union 결합, mapped type에서 키 이름 변경, 라우트에서 매개변수 추출, 타입 안전 이벤트 이미터 작성. 이 글을 읽고 나면 이 기법을 언제 써야 하는지, 어떻게 테스트하는지, 그리고 컴파일러를 느리게 하거나 이해하기 어려운 오류를 일으키는 함정들을 알게 될 것입니다.

이 글의 모든 예제는 TypeScript 5.x와 strict: true 설정을 사용합니다.

2. 기본 문법

Template literal type은 runtime의 template string과 똑같이 보입니다. 다른 점은 ${...} 부분이 값이 아닌 타입을 포함한다는 것입니다:

type OrderId = `ORD-${number}`;

const ok: OrderId = "ORD-1024";
// const bad: OrderId = "INV-1024";
// Error: Type '"INV-1024"' is not assignable to type '`ORD-${number}`'.

OrderId 타입은 ORD-로 시작하고 그 뒤에 숫자를 나타내는 문자열이 오는 모든 문자열을 허용합니다. 내삽할 수 있는 타입은 string, number, bigint, boolean, null, undefined와 이에 해당하는 literal type입니다.

익숙한 예제로는 CSS 단위를 포함한 값이 있습니다:

type Pixel = `${number}px`;

function setWidth(el: HTMLElement, width: Pixel): void {
  el.style.width = width;
}

setWidth(document.body, "320px"); // OK
// setWidth(document.body, "320");  // 오류: "px"가 필요합니다

이제 컴파일러가 단위를 빠뜨린 오류를 감지합니다. 예전에는 브라우저를 열 때까지 이 오류가 드러나지 않았습니다.

3. Union과 결합: 데카르트 곱

내삽 부분이 union이면 TypeScript는 template을 각 멤버에 분배합니다. 여러 union이 있으면 결과는 가능한 모든 조합입니다:

type Size = "sm" | "md" | "lg";
type Variant = "primary" | "danger";

type ButtonClass = `btn-${Size}-${Variant}`;

편집기에서 ButtonClass 위에 마우스를 올리면 다음을 볼 수 있습니다:

type ButtonClass = "btn-sm-primary" | "btn-sm-danger" | "btn-md-primary"
                 | "btn-md-danger" | "btn-lg-primary" | "btn-lg-danger"

여기서 3 × 2 = 6개 멤버가 자동으로 생성됩니다. Size에 "xl"을 추가하면 ButtonClass가 자동으로 업데이트됩니다. 다른 곳을 수정할 필요가 없습니다.

주의: 조합의 수는 기하급수적으로 증가합니다. 결과 union이 100,000개 멤버를 초과하면 TypeScript는 “Expression produces a union type that is too complex to represent” 오류를 보고합니다. 많은 큰 union을 함께 결합하지 마세요. 예를 들어 1,000개의 상품 코드 union과 200개의 국가 코드 union을 결합하는 것 같이요.

4. 내장 문자열 변환 타입

TypeScript는 대문자/소문자로 변경하기 위한 네 가지 intrinsic type을 제공합니다:

type Event = "orderCreated";

type A = Uppercase<Event>;     // "ORDERCREATED"
type B = Lowercase<Event>;     // "ordercreated"
type C = Capitalize<Event>;    // "OrderCreated"
type D = Uncapitalize<"OrderCreated">; // "orderCreated"

이들은 컴파일러에 내장되어 있으며 TypeScript로 작성되지 않습니다. 이 네 가지 타입은 보통 template literal과 함께 사용되어 관례에 따른 이름을 만듭니다. 예를 들어 on${Capitalize<K>} 또는 ${Uppercase<K>}_ENV처럼요.

type ConfigKey = "databaseUrl" | "apiToken";
type EnvName = `APP_${Uppercase<ConfigKey>}`;
// "APP_DATABASEURL" | "APP_APITOKEN"

5. Mapped Type에서 키 이름 변경

이것이 template literal types가 가장 유용하게 사용되는 부분입니다. mapped type의 as 절은 새로운 타입을 만들 때 각 키의 이름을 변경할 수 있게 해줍니다.

5.1. Interface에서 Getter 생성하기

interface Customer {
  name: string;
  email: string;
  loyaltyPoints: number;
}

type Getters<T> = {
  [K in keyof T & string as `get${Capitalize<K>}`]: () => T[K];
};

type CustomerGetters = Getters<Customer>;

결과:

type CustomerGetters = {
  getName: () => string;
  getEmail: () => string;
  getLoyaltyPoints: () => number;
}

각 부분의 설명:

  • keyof T & string은 number와 symbol 타입의 키를 필터링합니다. Capitalize는 string만 받기 때문입니다. & string을 빼면 “Type ‘K’ does not satisfy the constraint ‘string’” 오류가 발생합니다.
  • as \get${Capitalize}`는 name을 getName`으로 변경합니다.
  • () => T[K]는 원래 프로퍼티의 반환 타입을 유지합니다.

이 타입을 구현하는 클래스는 getter가 없거나 잘못된 타입을 반환하면 컴파일러가 즉시 오류를 보고합니다:

class CustomerView implements CustomerGetters {
  constructor(private readonly data: Customer) {}
  getName = () => this.data.name;
  getEmail = () => this.data.email;
  getLoyaltyPoints = () => this.data.loyaltyPoints;
}

5.2. never를 반환하여 키 필터링하기

as 내의 표현식이 never를 반환하면 그 키는 결과에서 제외됩니다. 이를 template literal과 결합하여 접두사에 따라 키를 선택할 수 있습니다:

interface PaymentService {
  chargeCard(amount: number): Promise<void>;
  chargeWallet(amount: number): Promise<void>;
  refund(paymentId: string): Promise<void>;
}

type ChargeMethods<T> = {
  [K in keyof T as K extends `charge${string}` ? K : never]: T[K];
};

type OnlyCharge = ChargeMethods<PaymentService>;
// { chargeCard: ...; chargeWallet: ... }

refund는 charge${string} 패턴과 일치하지 않으므로 제외됩니다.

6. infer를 사용한 문자열 분석

Template literal은 문자열을 결합하는 데만 사용되지 않습니다. conditional type에서 infer와 결합하여 문자열을 분해할 수도 있습니다.

6.1. 간단한 부분 추출하기

type StripPrefix<S extends string> =
  S extends `ORD-${infer Rest}` ? Rest : never;

type X = StripPrefix<"ORD-1024">; // "1024"
type Y = StripPrefix<"INV-1024">; // never

패턴이 일치하면 TypeScript는 나머지 부분을 Rest에 할당합니다.

6.2. 라우트에서 매개변수 추출하기

이것이 실제로 가장 자주 나타나는 예제입니다. 문자열 "/users/:userId/orders/:orderId"에서 "userId" | "orderId" 타입을 얻고 싶습니다:

type ExtractParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<`/${Rest}`>
    : Path extends `${string}:${infer Param}`
      ? Param
      : never;

type P = ExtractParams<"/users/:userId/orders/:orderId">;
// "userId" | "orderId"

작동 방식:

  1. 첫 번째 분기는 "/users/" + ":" + "userId" + "/" + "orders/:orderId"와 일치합니다. 각 자리 표시자는 그 뒤의 리터럴 문자의 첫 번째 출현까지 일치합니다. 따라서 Param은 "userId"입니다.
  2. 재귀적으로 "/orders/:orderId"를 처리합니다. 이번에는 매개변수 뒤에 /가 없으므로 첫 번째 분기가 실패합니다.
  3. 두 번째 분기가 일치하고 "orderId"를 반환합니다.
  4. 문자열에 :가 없으면 결과는 never입니다.

이제 이를 URL 생성 함수에서 사용합니다:

type RouteParams<Path extends string> = {
  [K in ExtractParams<Path>]: string;
};

function buildUrl<Path extends string>(
  path: Path,
  params: RouteParams<Path>,
): string {
  const values = params as Record<string, string>;
  return path.replace(/:(\w+)/g, (_, key: string) =>
    encodeURIComponent(values[key]),
  );
}

const url = buildUrl("/users/:userId/orders/:orderId", {
  userId: "42",
  orderId: "ORD-7",
});
console.log(url);

// buildUrl("/users/:userId", { id: "42" });
// 오류: Object literal may only specify known properties, and 'id' does not exist...

출력:

/users/42/orders/ORD-7

제네릭 Path는 첫 번째 인자에서 literal type으로 추론됩니다. 덕분에 params 객체가 정확하게 검사됩니다: 키가 빠졌거나, 불필요한 키가 있거나, 이름을 잘못 입력하면 모두 오류가 보고됩니다. 함수 본문에서 Record<string, string>으로 타입을 강제하는 것이 필요합니다. TypeScript는 런타임에 정규식에서 추출된 key가 ExtractParams<Path>에 속함을 증명할 수 없습니다.

7. 종합 예제: 타입 안전 이벤트 이미터

위의 기법들을 함께 사용하면, 주문 도메인을 위한 이미터를 만들 수 있습니다:

type OrderEvents = {
  created: { orderId: string; total: number };
  shipped: { orderId: string; carrier: string };
  cancelled: { orderId: string; reason: string };
};

type EventHandlers<E> = {
  [K in keyof E & string as `on${Capitalize<K>}`]?: (payload: E[K]) => void;
};

class OrderEmitter {
  constructor(private readonly handlers: EventHandlers<OrderEvents>) {}

  emit<K extends keyof OrderEvents>(event: K, payload: OrderEvents[K]): void {
    const name = `on${event[0].toUpperCase()}${event.slice(1)}` as
      `on${Capitalize<K>}`;
    const handler = this.handlers[name] as
      ((p: OrderEvents[K]) => void) | undefined;
    handler?.(payload);
  }
}

const emitter = new OrderEmitter({
  onCreated: (p) => console.log(`Created ${p.orderId}: ${p.total}`),
  onShipped: (p) => console.log(`Shipped via ${p.carrier}`),
});

emitter.emit("created", { orderId: "ORD-1", total: 250_000 });
emitter.emit("shipped", { orderId: "ORD-1", carrier: "GHN" });
// emitter.emit("shipped", { orderId: "ORD-1", reason: "x" }); // 오류

출력:

Created ORD-1: 250000
Shipped via GHN

얻을 수 있는 것:

  • 핸들러 이름 onCreated, onShipped, onCancelled이 OrderEvents에서 자동으로 생성됩니다.
  • 각 핸들러의 매개변수 p의 타입이 정확하게 payload 타입으로 추론되며, 별도로 선언할 필요가 없습니다.
  • emit("shipped", ...)은 payload가 carrier를 포함하도록 강제합니다.

emit의 두 as는 컴파일러가 스스로 추론할 수 없는 것을 명시하는 부분입니다. 런타임에 구성된 문자열은 타입 정보를 담지 않습니다. 이런 타입 강제를 작은 범위에서, 구현 부분에 제한하세요. 공개 API는 여전히 완전히 타입 안전합니다.

8. Vitest로 타입 테스트하기

타입 레벨의 로직도 테스트가 필요합니다. 그렇지 않으면 한 번의 리팩터링이 ExtractParams를 string으로 바꿔도 아무도 눈치채지 못할 수 있습니다. Vitest를 설치합니다:

npm install -D vitest typescript

타입 동작과 런타임 동작 모두에 대해 테스트를 작성합니다:

// route.test.ts
import { describe, it, expect, expectTypeOf } from "vitest";
import { buildUrl, type ExtractParams } from "./route";

describe("ExtractParams", () => {
  it("올바르게 매개변수 이름을 추출합니다", () => {
    expectTypeOf<ExtractParams<"/users/:userId/orders/:orderId">>()
      .toEqualTypeOf<"userId" | "orderId">();
  });

  it("매개변수가 없으면 never를 반환합니다", () => {
    expectTypeOf<ExtractParams<"/health">>().toBeNever();
  });
});

describe("buildUrl", () => {
  it("매개변수를 대체하고 값을 인코딩합니다", () => {
    const url = buildUrl("/customers/:customerId", { customerId: "a b" });
    expect(url).toBe("/customers/a%20b");
  });

  it("잘못된 키를 거부합니다", () => {
    // @ts-expect-error - 'id'는 라우트의 매개변수가 아닙니다
    buildUrl("/customers/:customerId", { id: "1" });
  });
});

실행합니다:

npx tsc --noEmit && npx vitest run

expectTypeOf는 런타임에 no-op입니다. 이 오류는 타입 체크 시에만 나타나므로 tsc --noEmit 단계(또는 vitest --typecheck)가 필수입니다. @ts-expect-error 지시문도 assertion입니다. 그 아래 줄에 더 이상 타입 오류가 없으면 tsc는 *“Unused ‘@ts-expect-error’ directive”*를 보고합니다.

9. 흔한 오류와 사용하지 말아야 할 경우

주의: Template literal types는 컴파일 시점에만 존재합니다. API, 폼 또는 process.env에서 온 데이터는 어떤 검사도 받지 않습니다. 외부 데이터의 경우, 정규식이나 schema 라이브러리를 사용하여 런타임에 검증해야 합니다.

구체적인 함정들:

  • ${number}는 생각보다 넓습니다. "1e3px"나 "0.5px"는 모두 `${number}px`와 유효합니다. “양의 정수”가 필요하면 template literal로 표현할 수 없습니다. 런타임에 검증하세요.
  • Union 폭증. 섹션 3에서처럼 많은 큰 union을 결합하면 컴파일러가 크게 느려지거나 너무 복잡하다는 오류를 보고합니다.
  • 깊은 재귀. ExtractParams와 같은 재귀 타입은 깊이 제한이 있습니다. 매우 긴 문자열의 경우 “Type instantiation is excessively deep and possibly infinite” 오류가 발생합니다. 일반적인 라우트는 이 한계에 도달하지 않지만, 일반적인 문자열 파서는 도달할 수 있습니다.
  • 읽기 어려운 오류 메시지. 복잡한 타입의 경우 오류가 수십 줄이 될 수 있습니다. 동료들이 매일 이 오류를 읽어야 한다면 비용이 이점보다 클 수 있습니다.
  • & string 누락. keyof T는 number | symbol을 포함할 수 있으며, Capitalize는 이를 거부합니다.
  • 남용. 값 집합이 작고 고정되어 있으면 직접 union literal을 작성하세요. type Size = "sm" | "md"를 작성하는 것이 어떤 자동 생성 타입보다 읽기 쉽습니다.

source of truth(인터페이스, 이벤트 객체, 라우트 문자열)가 있고 다른 타입들이 그로부터 자동으로 유도되기를 원할 때 사용하세요. 단순히 type-level 기술을 선보이기 위해 사용하지 마세요.

10. 결론

Template literal types는 문자열을 TypeScript의 타입 시스템에서 일급 시민으로 만듭니다. 이 글은 주요 기법들을 다루었습니다:

  • 문자열을 결합하고 union을 통해 분배하기;
  • Uppercase, Capitalize 등으로 대소문자 변경하기;
  • mapped type에서 as를 사용하여 키 이름 변경하고 필터링하기;
  • infer를 사용하여 문자열을 분해하기(재귀적으로도).

라우트 매개변수, 이벤트 이름, getter처럼 올바른 곳에 적용하면, 이전에는 런타임에만 발견되던 오타 오류의 전체 클래스를 제거할 수 있습니다. expectTypeOf와 @ts-expect-error를 사용하여 타입을 테스트하는 것을 기억하세요. union의 복잡성과 재귀의 깊이에 주의하세요. 그리고 항상 프로그램 외부에서 오는 데이터를 검증하세요.

typescripttemplate literal typestype systemgenericsvitest