Ayden's journal

템플릿 리터럴 타입

자바스크립트에서 템플릿 리터럴이란 변수를 문자열 내에 삽입할 수 있도록 하는 문법을 지칭한다. 타입스크립트에서도 이와 유사하게 템플릿 리터럴 타입이 존재한다. 기존의 리터럴 타입 및 유니언 타입을 변수처럼 사용하여 새로운 문자열 타입을 구성하는 방식이다. 이를 통해 문자열을 결합하거나 변형한 새로운 타입을 정의할 수 있으며, 타입스크립트의 강력한 타입 시스템과 결합하여 타입 안전성을 유지하면서도 유연하게 문자열 기반 타입을 다룰 수 있다.

// 일반적인 템플릿 리터럴 타입의 사용법
type VerticalAlignment = "top" | "middle" | "bottom";
type HorizontalAlignment = "left" | "center" | "right";

// type Alignment =
//   | "top-left"    | "top-center"    | "top-right"
//   | "middle-left" | "middle-center" | "middle-right"
//   | "bottom-left" | "bottom-center" | "bottom-right"
type Alignment = `${VerticalAlignment}-${HorizontalAlignment}`;

기존에 나는 위와 같이 문자열 타입과 문자열 타입을 조합하는 방식으로 이 템플릿 리터럴 타입을 활용해왔다. 더 작은 타입이 더 큰 타입을 구성하므로 유지보수의 관점에서도 각각의 타입 아홉 개를 모두 선언해주는 것보다야 훨씬 나은 방식이라고 할 수 있겠다. 그런데 나는 최근에 Next.js의 Image 컴포넌트를 뜯어보다가 number 타입을 사용해 템플릿 리터럴 타입을 구성할 수 있다는 사실을 알게 되었다.

export declare const Image: React.ForwardRefExoticComponent<Omit<React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>, "height" | "width" | "loading" | "ref" | "alt" | "src" | "srcSet"> & {
    src: string | import("../shared/lib/get-img-props").StaticImport;
    alt: string;
    width?: number | `${number}`;
    height?: number | `${number}`;

이렇게 사용할 경우 사용자로부터 width={123} 뿐만 아니라 width="123"과 같은 props를 넣어줘도 문제 없이 작동하며, width="fxxk"과 같은 문자열을 props으로 사용하게 될 경우 타입 오류를 던지게 된다. 그리고 이를 조금 더 적극적으로 사용하면 아래와 같은 방식으로 입력 값이 특정 숫자 범위 안에 포함되는지를 확인할 수 있다.

// 숫자로 표현할 수 있는 타입의 제한
type Numeric = number | `${number}`;

// 재귀적으로 호출되며 다음 숫자를 찾아준다
type Increment<T extends number, Acc extends number[] = []> = 
  Acc['length'] extends T ? [...Acc, 0]['length'] : Increment<T, [...Acc, 0]>

// 재귀적으로 호출되며 Start부터 End까지 이어지는 number 타입의 유니언 타입을 리턴한다
type RangeHelper<Start extends number, End extends number, Acc extends number[] = [Start]> = 
  Start extends End
    ? [...Acc, End][number]
    : RangeHelper<Increment<Start>, End, [...Acc, Increment<Start>]>;

// string 타입을 number 타입으로 변환시킴
type StringToNumber<T extends Numeric> =
  T extends number
    ? T
    : T extends `${infer R extends number}`
      ? R
      : never;

// number 타입과 `${number}` 타입을 받아서, 해당 숫자가 해당 범위 내에 있는지를 확인하는 타입
type IsInRange<T extends Numeric, Min extends Numeric = 0, Max extends Numeric = 1000> = 
  T extends string 
    ? StringToNumber<T> extends RangeHelper<StringToNumber<Min>, StringToNumber<Max>>
      ? `${T}`
      : never
    : T extends RangeHelper<StringToNumber<Min>, StringToNumber<Max>>
      ? T
      : never
const number1 = "20"
const number2 = 200

// a = "20"
const a: IsInRange<typeof number1, "10", 934> = number1

// 'number' 형식은 'never' 형식에 할당할 수 없습니다.
const b: IsInRange<typeof number2, 400, "900"> = number2

 

 

+ 위에서 본 IsInRange 타입은 재귀 타입, 조건부 타입, 제네릭 타입, infer 타입, 템플릿 리터럴 타입 등을 모조리 가져다 만든 타입이다. 하지만 안타깝게도 IsInRange<"1432", 0, 10000>과 같이 너무 깊은 재귀 호출을 필요로 하는 타입은 마치 JS의 스택오버플로우 같은 현상을 일으킨다. 확인해본 결과로는 1000번보다 더 깊게 호출되는 경우 TS엔진이 추론을 포기하고 대신 any로 추론하는 듯하다. 아쉽지만 IsInRange 타입은 좁은 범위 내에서만 사용해야겠다.

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기