타입스크립트 심화 #4 Template literal types
#3 Conditional types와 infer 에서 분기와 추출을 배웠습니다. 이번 글에서 다룰 도구는 — 타입 단계에서 문자열을 합성하는 template literal types 입니다. 자바스크립트의 `${...}` 와 모양이 똑같지만, 타입 자리에서 동작해요.
기본 — 문자열 리터럴을 합성하기
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를 자동으로 만들어내기.
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;
// }세 가지 도구가 합쳐져 있습니다.
- mapped type — 키 하나하나에 대해
- template literal —
set+대문자화된 키 Capitalize<string & K>— K가 string일 때만 첫 글자 대문자
string & K 의 의미는 "K가 symbol/number 키일 수도 있으니, 그 중 string인 것만" 입니다. mapped type을 쓸 때 자주 등장하는 관용구예요.
실전 2 — 이벤트 핸들러 이름 매핑
DOM 이벤트나 커스텀 이벤트의 이름에서 핸들러 이름을 자동 생성.
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 + 재귀가 모두 들어간 코드입니다. 처음 보면 무서워 보이지만 한 줄씩 읽으면 어렵지 않아요.
${string}:${infer P}/${infer Rest}—:이름/...패턴이면 이름과 나머지를 잡아냄P | ExtractParams<...>— 잡은 이름과 나머지에서 또 추출:이름만 있고 뒤가 없으면 그게 마지막 파라미터
이 패턴이 들어가면 다음과 같은 안전한 라우터 함수가 가능합니다.
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까지 정리합니다.