Javascript

TypeScript의 템플릿 리터럴 타입: 타입 검사 중 파싱 및 활용 방법

드리프트2 2025. 2. 13. 22:50

TypeScript의 템플릿 리터럴 타입: 타입 검사 중 파싱 및 활용 방법

TypeScript 템플릿 리터럴 타입: 기초 이론과 기본 활용

TypeScript는 JavaScript의 슈퍼셋으로, 정적 타입 시스템을 제공하여 코드의 안정성을 높여줍니다.

 

특히 템플릿 리터럴 타입(Template Literal Types)은 TypeScript 4.1에서 도입된 기능으로, 문자열 리터럴 타입을 더 유연하게 다룰 수 있게 해줍니다.

 

이번 포스트에서는 템플릿 리터럴 타입의 기본 개념간단한 활용법을 알아보겠습니다.


템플릿 리터럴 타입이란?

템플릿 리터럴 타입은 JavaScript의 템플릿 리터럴(백틱(`)을 사용한 문자열)과 비슷한 문법을 가지고 있지만, 타입 수준에서 동작합니다.

 

이를 통해 문자열 리터럴 타입을 조합하거나 변환할 수 있으며, 타입 시스템 내에서 문자열의 형식을 강제할 수 있습니다.


이 블로그 게시물에서 사용되는 표기법

타입 검증을 위해 ts-expect라는 npm 패키지를 사용합니다. 이 패키지는 타입이 예상대로 동작하는지 검증하는 데 유용합니다.

// 값의 타입 검증
expectType<string>('abc'); // 'abc'는 string 타입입니다.
expectType<number>(123);   // 123은 number 타입입니다.

// 타입의 동일성 검증
type Pair<T> = [T, T];
expectType<TypeEqual<
  Pair<string>, [string, string]
>>(true); // Pair<string>은 [string, string]과 동일합니다.

템플릿 리터럴 타입의 기본 문법

템플릿 리터럴 타입은 백틱()을 사용하여 문자열 리터럴 타입을 정의합니다.

 

이 때 ${}` 안에 타입을 넣어 동적으로 문자열을 조합할 수 있습니다.

type Song<Num extends number, Bev extends string> =
  `${Num} bottles of ${Bev}`;

 

위 코드에서 Song 타입은 두 개의 제네릭 매개변수(NumBev)를 받아 문자열 리터럴 타입을 생성합니다.

 

예를 들어, Song<99, 'juice'>'99 bottles of juice'라는 문자열 리터럴 타입이 됩니다.


연결은 분배적입니다

템플릿 리터럴 타입은 문자열 리터럴 유니온 타입과 함께 사용될 때 분배적으로 동작합니다.

 

즉, 유니온 타입의 각 멤버에 대해 템플릿 리터럴 타입이 적용됩니다.

type Modules = 'fs' | 'os' | 'path';
type Prefixed = `node:${Modules}`;

expectType<TypeEqual<
  Prefixed, 'node:fs' | 'node:os' | 'node:path'
>>(true);

 

위 예제에서 Prefixed'node:fs', 'node:os', 'node:path'의 유니온 타입이 됩니다.


하위 문자열 추출

템플릿 리터럴 타입에서 infer 키워드를 사용하면 문자열의 일부분을 추출할 수 있습니다.

 

이는 정규 표현식의 캡처 그룹과 유사한 기능입니다.

type ParseSemver<Str extends string> =
  Str extends `${infer Major}.${infer Minor}.${infer Patch}`
    ? [Major, Minor, Patch]
    : never;

expectType<TypeEqual<
  ParseSemver<'1.2.3'>, ['1', '2', '3']
>>(true);

 

위 코드에서 ParseSemver'1.2.3'과 같은 문자열을 ['1', '2', '3']으로 분해합니다.


문자열 값 제약

템플릿 리터럴 타입을 사용하면 특정 형식을 따르는 문자열 리터럴 타입을 정의할 수 있습니다.

 

예를 들어, 버전 문자열을 v숫자.숫자 형식으로 제약할 수 있습니다.

type Version = `v${number}.${number}`;

const version1: Version = 'v1.0'; // OK
const version2: Version = 'v2.zero'; // Error: 'zero'는 숫자가 아닙니다.

유틸리티 타입

TypeScript는 문자열 리터럴 타입을 조작하기 위한 네 가지 유틸리티 타입을 제공합니다.

  1. Uppercase: 문자열을 대문자로 변환
  2. expectType<TypeEqual< Uppercase<'hello'>, 'HELLO' >>(true);
  3. Lowercase: 문자열을 소문자로 변환
  4. expectType<TypeEqual< Lowercase<'HELLO'>, 'hello' >>(true);
  5. Capitalize: 문자열의 첫 글자를 대문자로 변환
  6. expectType<TypeEqual< Capitalize<'hello'>, 'Hello' >>(true);
  7. Uncapitalize: 문자열의 첫 글자를 소문자로 변환
  8. expectType<TypeEqual< Uncapitalize<'HELLO'>, 'hELLO' >>(true);

튜플 작업

템플릿 리터럴 타입은 튜플과 함께 사용될 때도 유용합니다.

 

예를 들어, 문자열 튜플을 하나의 문자열로 결합할 수 있습니다.

type Join<Strs extends string[], Sep extends string = ','> =
  Strs extends [
    infer First extends string,
    ...infer Rest extends string[]
  ]
    ? Rest['length'] extends 0
      ? First
      : `${First}${Sep}${Join<Rest, Sep>}`
    : '';

expectType<TypeEqual<
  Join<['Hello', 'How', 'Are', 'You'], ' '>,
  'Hello How Are You'
>>(true);

 

TypeScript 템플릿 리터럴 타입: 고급 활용과 실제 예제

 

이번에는 템플릿 리터럴 타입을 활용한 고급 기법실제 프로젝트에서의 활용 예제를 살펴보겠습니다.

 

템플릿 리터럴 타입은 문자열 리터럴을 타입 수준에서 다룰 수 있게 해주어, 복잡한 타입 시스템을 구축하는 데 매우 유용합니다.


객체 작업

템플릿 리터럴 타입은 객체의 프로퍼티 키를 동적으로 변환하거나 생성하는 데에도 사용할 수 있습니다.

 

예를 들어, 객체의 모든 키에 접두사를 추가하는 작업을 타입 수준에서 처리할 수 있습니다.

예: 속성 이름에 접두사 추가

type PrependDollarSign<Obj> = {
  [Key in (keyof Obj & string) as `$${Key}`]: Obj[Key];
};

type Items = {
  count: number,
  item: string,
  [Symbol.toStringTag]: string,
};

expectType<TypeEqual<
  PrependDollarSign<Items>,
  {
    $count: number,
    $item: string,
    // 생략: [Symbol.toStringTag]
  }
>>(true);

 

위 코드에서는 PrependDollarSign 타입이 객체의 모든 키에 $ 접두사를 추가합니다.

Symbol.toStringTag와 같은 심볼 키는 변환되지 않습니다.


실제 예제

템플릿 리터럴 타입은 다양한 실제 상황에서 유용하게 사용될 수 있습니다.

 

아래는 몇 가지 흥미로운 예제입니다.

 

예: 터미널에 출력 스타일 지정

Node.js에서 터미널에 스타일을 적용해 텍스트를 출력할 때, 템플릿 리터럴 타입을 사용해 스타일 문자열의 형식을 강제할 수 있습니다.

const styles = [
  'bold',
  'italic',
  'underline',
  'red',
  'green',
  'blue',
] as const;

type StyleUnion = typeof styles[number];
type StyleTextFormat =
  | `${StyleUnion}`
  | `${StyleUnion}+${StyleUnion}`
  | `${StyleUnion}+${StyleUnion}+${StyleUnion}`;

function styleText(format: StyleTextFormat, text: string): string {
  return util.styleText(format.split('+'), text);
}

styleText('bold+underline+red', 'Hello!'); // OK
styleText('bol+underline+red', 'Hello!'); // Error: 'bol'은 유효한 스타일이 아닙니다.

 

이 예제에서는 StyleTextFormat 타입을 사용해 스타일 문자열의 형식을 정적으로 검사합니다.


예: 속성 경로

객체의 중첩된 속성 경로를 타입으로 정의할 때 템플릿 리터럴 타입을 사용할 수 있습니다.

type PropType<T, Path extends string> =
  Path extends keyof T
    ? T[Path]
    : Path extends `${infer First}.${infer Rest}`
      ? First extends keyof T
        ? PropType<T[First], Rest>
        : unknown
      : unknown;

const obj = { a: { b: ['x', 'y'] } } as const;

expectType<
  readonly ['x', 'y']
>(getPropValue(obj, 'a.b'));

expectType<
  'y'
>(getPropValue(obj, 'a.b.1'));

 

위 코드에서 PropType은 객체의 중첩된 속성 경로를 타입으로 추론합니다.


예: 속성 이름 접두사 변경

JSON-LD 형식의 객체에서 @로 시작하는 키를 _로 변경하는 작업을 타입 수준에서 처리할 수 있습니다.

type PropKeysAtToUnderscore<Obj> = {
  [Key in keyof Obj as AtToUnderscore<Key>]: Obj[Key];
};

type AtToUnderscore<Key> =
  Key extends `@${infer Rest}` ? `_${Rest}` : Key;

type JsonLd = {
  '@context': string,
  '@type': string,
  datePublished: string,
};

expectType<TypeEqual<
  PropKeysAtToUnderscore<JsonLd>,
  {
    _context: string,
    _type: string,
    datePublished: string,
  }
>>(true);

예: 카멜 케이스를 하이픈 케이스로 변환

카멜 케이스(camelCase)로 작성된 문자열을 하이픈 케이스(kebab-case)로 변환하는 작업을 타입 수준에서 처리할 수 있습니다.

type ToHyphenCase<Str extends string> =
  HyphenateWords<SplitCamelCase<Str>>;

type SplitCamelCase<Str extends string> =
  Str extends `${infer First}${infer Rest}`
    ? IsUppercase<First> extends true
      ? [First, ...SplitCamelCase<Rest>]
      : [`${First}`, ...SplitCamelCase<Rest>]
    : [];

type HyphenateWords<Words extends string[]> =
  Words extends [infer First, ...infer Rest]
    ? Rest['length'] extends 0
      ? Lowercase<First>
      : `${Lowercase<First>}-${HyphenateWords<Rest>}`
    : '';

expectType<TypeEqual<
  ToHyphenCase<'howAreYou'>,
  'how-are-you'
>>(true);

예: 하이픈 케이스를 카멜 케이스로 변환

반대로 하이픈 케이스를 카멜 케이스로 변환하는 작업도 가능합니다.

type ToLowerCamelCase<Str extends string> =
  Uncapitalize<ToUpperCamelCase<Str>>;

type ToUpperCamelCase<Str extends string> =
  camelizeWords<Split<Str, '-'>>;

type camelizeWords<Words extends string[]> =
  Words extends [infer First, ...infer Rest]
    ? Rest['length'] extends 0
      ? Capitalize<First>
      : `${Capitalize<First>}${camelizeWords<Rest>}`
    : '';

expectType<TypeEqual<
  ToLowerCamelCase<'how-are-you'>,
  'howAreYou'
>>(true);

사람들이 템플릿 리터럴 타입으로 하는 멋진 작업

템플릿 리터럴 타입은 다양한 창의적인 방식으로 활용되고 있습니다. 몇 가지 예를 살펴보겠습니다.

  1. Node.js: UUID 타입 정의
  2. type UUID = `${string}-${string}-${string}-${string}-${string}`;
  3. CLI 인자 파싱
  4. const opts = program .option("-e, --episode <num>", "Download episode No. <num>") .option("--keep", "Keeps temporary files") .opts();
  5. DOM 요소 타입 추론
  6. const a = querySelector('div.banner > a.call-to-action'); // HTMLAnchorElement
  7. Tailwind 색상 변형
  8. type TailwindColor = `${BaseColor}-${Variant}`;

끝맺음

템플릿 리터럴 타입은 TypeScript에서 매우 강력한 도구이지만, 몇 가지 주의사항이 있습니다.

  1. 오류 메시지가 명확하지 않을 수 있습니다.
  2. 타입 수준의 코드는 이해하기 어려울 수 있습니다.
  3. 재귀를 사용할 경우 타입 검사 속도가 느려질 수 있습니다.

템플릿 리터럴 타입은 복잡한 문자열 리터럴 타입을 다루는 데 매우 유용하지만, 적절히 활용하는 것이 중요합니다.