타입스크립트 심화 #4 Template literal types

#3 Conditional types와 infer 에서 분기와 추출을 배웠습니다. 이번 글에서 다룰 도구는 — 타입 단계에서 문자열을 합성하는 template literal types 입니다. 자바스크립트의 `${...}` 와 모양이 똑같지만, 타입 자리에서 동작해요.

기본 — 문자열 리터럴을 합성하기

template literal type 기본
type Greeting = `hello, ${string}`;
 
const a: Greeting = 'hello, curtis';   // OK
const b: Greeting = 'hi, curtis';       // ✗ 'hello, '로 시작해야 함
const c: Greeting = 'hello, ';          // OK (뒤가 빈 문자열도 string)

${string} 자리에 어떤 문자열이든 들어올 수 있어요. 모양은 hello, 로 시작해야 합니다. 이렇게 두면 컴파일 타임에 패턴을 강제할 수 있어요.

자리 표시자에는 string 외에도 number, boolean, 또는 union 리터럴을 넣을 수 있습니다.

자리에 무엇이 들어가느냐
type ID = `user-${number}`;
const a: ID = 'user-42';      // OK
const b: ID = 'user-abc';     // ✗ number 자리
 
type Color = 'red' | 'blue' | 'green';
type ColorClass = `color-${Color}`;
// 'color-red' | 'color-blue' | 'color-green'

Color 같은 union을 넣으면 결과 union으로 분배됩니다. #3 의 분배 조건부와 같은 원리예요.

union이 두 개 들어가면 — 곱집합

자리가 두 개 있고 둘 다 union이면, 카테시안 곱이 만들어집니다.

곱집합으로 늘어남
type Side = 'top' | 'right' | 'bottom' | 'left';
type Size = 'sm' | 'md' | 'lg';
 
type Spacing = `m-${Side}-${Size}`;
// 'm-top-sm' | 'm-top-md' | ... | 'm-left-lg'
// 총 12개

CSS 클래스 네임 같은 자리에서 가치가 큽니다. m-top-sm 같은 게 12개나 만들어졌고, 새 size를 추가하면 자동으로 4개씩 늘어나요.

빌트인 문자열 도우미

template literal type 옆에는 네 가지 빌트인 도우미가 있습니다.

도우미결과
Uppercase<S>'hello' → 'HELLO'
Lowercase<S>'HELLO' → 'hello'
Capitalize<S>'hello' → 'Hello'
Uncapitalize<S>'Hello' → 'hello'
빌트인 도우미
type A = Uppercase<'hello'>;     // 'HELLO'
type B = Capitalize<'curtis'>;   // 'Curtis'
type C = Uncapitalize<'Curtis'>; // 'curtis'
 
type EventNames = Uppercase<'click' | 'change'>;
// 'CLICK' | 'CHANGE'

타입 단계에서 문자열을 가공한다는 게 기묘하게 보일 수 있는데, 라이브러리 타입을 짤 때 매우 자주 등장합니다.

실전 1 — setter 메서드 자동 생성

#2 에서 살짝 봤던 패턴. 객체의 각 키마다 setter를 자동으로 만들어내기.

setter 메서드 자동 생성
type Setters<T> = {
  [K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
};
 
type UserSetters = Setters<{ name: string; age: number; email: string }>;
// {
//   setName: (value: string) => void;
//   setAge: (value: number) => void;
//   setEmail: (value: string) => void;
// }

세 가지 도구가 합쳐져 있습니다.

  1. mapped type — 키 하나하나에 대해
  2. template literalset + 대문자화된 키
  3. Capitalize<string & K> — K가 string일 때만 첫 글자 대문자

string & K 의 의미는 "K가 symbol/number 키일 수도 있으니, 그 중 string인 것만" 입니다. mapped type을 쓸 때 자주 등장하는 관용구예요.

실전 2 — 이벤트 핸들러 이름 매핑

DOM 이벤트나 커스텀 이벤트의 이름에서 핸들러 이름을 자동 생성.

event → handler 이름
type EventName = 'click' | 'change' | 'focus' | 'blur';
type HandlerName = `on${Capitalize<EventName>}`;
// 'onClick' | 'onChange' | 'onFocus' | 'onBlur'
 
type Handlers = {
  [K in HandlerName]: (e: Event) => void;
};
// {
//   onClick: (e: Event) => void;
//   onChange: (e: Event) => void;
//   ...
// }

리액트가 사용하는 이벤트 핸들러 이름 컨벤션이 정확히 이 패턴입니다. 라이브러리 안쪽에서 비슷한 자동 생성을 자주 만나요.

실전 3 — 라우트 파라미터 추출

라우트 패턴 문자열에서 파라미터 이름을 끌어내는 — 타입스크립트의 가장 유명한 트릭 중 하나입니다.

라우트에서 파라미터 추출
type ExtractParams<S extends string> =
  S extends `${string}:${infer P}/${infer Rest}`
    ? P | ExtractParams<`/${Rest}`>
    : S extends `${string}:${infer P}`
      ? P
      : never;
 
type A = ExtractParams<'/users/:id'>;          // 'id'
type B = ExtractParams<'/users/:userId/posts/:postId'>;
// 'userId' | 'postId'

template literal + infer + 재귀가 모두 들어간 코드입니다. 처음 보면 무서워 보이지만 한 줄씩 읽으면 어렵지 않아요.

  1. ${string}:${infer P}/${infer Rest}:이름/... 패턴이면 이름과 나머지를 잡아냄
  2. P | ExtractParams<...> — 잡은 이름과 나머지에서 또 추출
  3. :이름 만 있고 뒤가 없으면 그게 마지막 파라미터

이 패턴이 들어가면 다음과 같은 안전한 라우터 함수가 가능합니다.

안전한 navigate
function navigate<S extends string>(
  pattern: S,
  params: Record<ExtractParams<S>, string>
): void {
  // ...
}
 
navigate('/users/:id', { id: '42' });          // OK
navigate('/users/:id', { name: '커티스' });     // ✗ id 누락
navigate('/users/:id/posts/:postId', {
  id: '1',
  postId: '7',
});                                              // OK

라우트 패턴만 보고 어떤 파라미터가 필요한지를 컴파일러가 정확히 추론합니다. Next.js나 React Router 의 타입 정의 안쪽에 비슷한 패턴이 들어 있어요.

실전 4 — CSS 단위 강제

CSS 값을 받는 prop이 단순 string이면 사용자가 "20" 같은 단위 빠진 문자열을 보낼 수 있습니다. template literal로 단위를 강제할 수 있어요.

단위 강제
type CSSUnit = 'px' | 'rem' | 'em' | '%';
type CSSValue = `${number}${CSSUnit}`;
 
const a: CSSValue = '20px';     // OK
const b: CSSValue = '1.5rem';   // OK
const c: CSSValue = '20';       // ✗ 단위 없음
const d: CSSValue = 'auto';     // ✗ 'auto'를 허용하려면 union 추가

쓸데없이 빡빡할 수도 있지만, 디자인 시스템 컴포넌트의 prop으로 쓰면 실수가 줄어들어요. 'auto' | 'inherit' 같은 키워드가 필요하면 union에 추가합니다.

함정 — string은 너무 넓다

template literal에 ${string} 을 넣으면 모든 string을 받아주는 자리가 생기기 때문에, 결과 타입도 결국 어떤 문자열이든 통과해 버립니다.

너무 넓은 자리
type ApiPath = `/api/${string}`;
 
const a: ApiPath = '/api/users';   // OK
const b: ApiPath = '/api/';        // OK
const c: ApiPath = '/api/foo bar'; // OK — 공백도 통과

좁히고 싶다면 ${string} 대신 더 좁은 union을 쓰거나, 별도 검증 로직을 곁들여야 합니다. 타입스크립트만으로 모든 형식을 강제하긴 어렵다는 걸 의식해 두세요.

함정 — 너무 깊은 재귀는 막힘

라우트 파라미터 추출 같은 재귀 패턴은 강력하지만, 타입스크립트의 재귀 깊이 한계(약 50회) 가 있어 너무 긴 입력에서는 멈춥니다.

재귀 한계
// 50개 이상의 :param이 있는 라우트는 처리 못 함
type Many = ExtractParams<'/a/:p1/:p2/.../:p100'>;
// 컴파일러가 포기 (Type instantiation is excessively deep)

실무에서는 거의 부딪힐 일이 없지만, 라이브러리 타입을 짤 때 한 번씩 만납니다. 안전을 위해 재귀 깊이를 의식해서 짠다면 꼬리 재귀 형태가 더 잘 견딥니다 (잡은 결과를 매개변수로 누적해 가는 형태).

마무리

이번 글에서 정리한 내용:

  • template literal type — `${...}` 로 문자열 합성
  • 자리에 string/number/literal union 가능
  • union이 들어가면 곱집합으로 분배
  • 빌트인 도우미 — Uppercase / Lowercase / Capitalize / Uncapitalize
  • mapped + template literal로 키 이름 자동 생성 (setter 패턴)
  • infer + 재귀 + template literal로 라우트 파라미터 추출 같은 트릭
  • ${string} 은 너무 넓다 — 좁히기는 별도 검증 필요
  • 재귀 깊이 한계 의식

다음 글(#5 Discriminated union과 타입 가드 깊이)에서는 분기 가능한 타입 모델링 — discriminated union을 깊이 다루고, 사용자 정의 타입 가드와 assertion 함수, 그리고 branded type까지 정리합니다.