Ayden's journal

유틸리티 타입 IsInRange

프론트엔드 개발을 하다 보면 컴포넌트의 props로 number 타입을 받을 때가 많다. 하지만 단순히 number 타입만 지정하면 값의 범위를 제한할 수 없다는 점이 아쉽다. 예를 들어, 페이지네이션의 currentPage는 1 이상의 값만 허용해야 하고, 슬라이더의 value는 최소/최대 값을 벗어나지 않아야 한다. 이를 보완하기 위해 TypeScript의 타입 시스템을 활용해 특정 범위 내의 숫자만 허용하는 유틸리티 타입 IsInRange를 만들어야겠다고 생각했다.

 

처음에는 아래와 같이 재귀적으로 타입을 호출하며 범위 내에 특정 숫자 리터럴 타입이 존재하는지를 검사했다. 이러한 방법은 논리적으로 문제가 없지만, 실제로는 몇 가지 하자가 존재한다. 우선 범위가 1000이 넘어가버리면 콜스택이 폭발해버리고 만다. 이는 타입스크립트의 근본적인 설계에 기반한 문제이기 때문에 내가 어떻게 손 쓸 수가 없다. 다른 문제는 음수 범위에 대한 고려가 전혀 없었다는 것인데, 당시에는 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 = 100> =
  T extends string
    ? StringToNumber<T> extends RangeHelper<StringToNumber<Min>, StringToNumber<Max>>
      ? `${T}`
        : never
      : T extends RangeHelper<StringToNumber<Min>, StringToNumber<Max>>
        ? T
        : never

 

유틸리티 타입 IsInRange의 첫 번째 버전은 이렇게 반쪽짜리 성공으로 마무리되었다. 실제 프로젝트에서 사용해 보니 한계가 명확했지만, 그렇다고 완전히 쓸모없는 것은 아니었다. 특정한 작은 범위에서는 정상적으로 동작했고, 양수 범위 내에서는 원하는 타입 제한을 걸 수 있었다. 덕분에 잘못된 숫자가 props로 들어오는 실수를 줄일 수 있었고, 코드의 안정성을 높이는 데 일정 부분 기여했다.

하지만 범위가 커질수록 타입스크립트의 한계로 인해 현실적으로 사용하기 어려워졌고, 음수 값을 다룰 수 없다는 문제도 점점 더 신경 쓰이기 시작했다. 결국, 보다 확장성과 안정성이 높은 해결책을 고민하게 되었다.

 

타입스크립트 기술을 갈고 닦기 위해 몇 주 전부터 type-challenges 문제를 풀고 있었다. 그 과정에서 숫자를 다루는 다양한 유틸리티 타입을 접하게 되었고, 특히 정수 연산과 비교 연산을 타입 수준에서 구현하는 방법에 흥미를 느꼈다. 문제를 풀다 보니 자연스럽게 타입스크립트의 한계를 더 깊이 이해하게 되었고, 이를 우회할 수 있는 여러 가지 기법들도 익히게 되었다. 그러던 중 기존 IsInRange 타입의 문제점을 해결할 실마리를 발견했고, 이를 개선해 보다 범용적으로 활용할 수 있는 방식으로 발전시켜 보기로 했다.

직접적으로 영향을 받은 문제는 4425 - Greater Than이다. 만약 어떤 두 숫자 리터럴 타입을 비교하여 크고 작음을 알 수 있다면, 이를 활용해 어떤 숫자 리터럴 타입이 두 숫자 리터럴 타입의 범위 내에 있는지를 쉽게 알아낼 수 있다. 문제는 숫자 리터럴 타입의 비교인데, 타입스크립트는 절망적일 정도로 숫자 리터럴 타입의 연산에 취약하다. 따라서 이를 문자열로 변환하고 자릿수를 하나씩 뜯어낸 다음, 유니언 타입을 사용해 크고 작음을 가늠해보기로 했다.

 

두 번째 버전의 IsInRange는 첫 번째 버전의 단점을 상당수 개선했다. 이제 음수 범위의 비교를 처리할 수 있을 뿐 아니라 음수로부터 양수까지의 범위 비교를 처리한다. 또한 재귀적인 부분이 없기 때문에 1000을 넘는 범위의 비교도 문제 없이 처리할 수 있다. 아래는 유틸리티 타입 IsInRange이 실제로 이러한 동작을 처리할 수 있도록 하기 위해 작성한 보조 타입들이다.

// 각 숫자보다 큰 숫자들의 집합을 정의한 타입 사전
// 예: "0"보다 큰 숫자는 1~9, "5"보다 큰 숫자는 6~9 등
type GreaterThanDict = {
  "0": "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
  "1": "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
  "2": "3" | "4" | "5" | "6" | "7" | "8" | "9"
  "3": "4" | "5" | "6" | "7" | "8" | "9"
  "4": "5" | "6" | "7" | "8" | "9"
  "5": "6" | "7" | "8" | "9";
  "6": "7" | "8" | "9";
  "7": "8" | "9";
  "8": "9";
  "9": never; // 9보다 큰 한 자리 숫자는 없음
};

// 숫자를 문자열로 표현한 타입 (예: "0", "1", ..., "9")
type DigitCharacter = keyof GreaterThanDict

// 두 개의 단일 자릿수를 비교하여 첫 번째가 두 번째보다 큰지 확인하는 타입
type GreaterThan_Digit<
  D1 extends DigitCharacter,
  D2 extends DigitCharacter
> = D1 extends GreaterThanDict[D2] ? true : false;

// 문자열이 음수인지 확인하는 타입 (- 기호로 시작하는지 검사)
type IsNegative<T extends string> = T extends `-${string}` ? true : false;

// 문자열로 표현된 숫자의 절대값을 구하는 타입 (음수 부호 제거)
type Abs<T extends string> = T extends `-${infer R}` ? R : T;

// 숫자 문자열을 개별 자릿수 문자열의 배열로 변환하는 타입
// 예: "123" → ["1", "2", "3"]
type NumberStringToDigitStringArr<
  NumberString extends string,
  Result extends unknown[] = []
> = NumberString extends `${infer L}${infer Rest}`
  ? NumberStringToDigitStringArr<Rest, [...Result, L]>
    : Result extends DigitCharacter[] 
    ? Result
  : never;

// 숫자를 개별 자릿수 문자열의 배열로 변환하는 타입
// 음수의 경우 절대값을 사용함
type NumberToDigitStringArr<N extends number> = NumberStringToDigitStringArr<Abs<`${N}`>>;

// 두 개의 자릿수 배열을 비교하여 첫 번째가 두 번째보다 큰지 확인하는 타입
type GreaterThan_DigitArray<
  TA extends DigitCharacter[],
  UA extends DigitCharacter[],
  NumberTDigits extends number = TA["length"],
  NumberUDigits extends number = UA["length"]
> = NumberTDigits extends NumberUDigits
  ? TA extends [
      infer TDigit extends DigitCharacter,
      ...infer TRest extends DigitCharacter[]
    ]
    ? UA extends [
        infer UDigit extends DigitCharacter,
        ...infer URest extends DigitCharacter[]
      ]
      ? GreaterThan_Digit<TDigit, UDigit> extends true
        ? true // 첫 자리가 크면 전체가 큼
        : GreaterThan_Digit<UDigit, TDigit> extends true
        ? false // 첫 자리가 작으면 전체가 작음
        : GreaterThan_DigitArray<TRest, URest> // 첫 자리가 같으면 나머지 자리 비교
      : never
    : false
  : GreaterThan<NumberTDigits, NumberUDigits>; // 자릿수가 다르면 자릿수로 비교 (자릿수가 많은 쪽이 큼)

// 두 숫자를 비교하여 첫 번째가 두 번째보다 큰지 확인하는 타입
type GreaterThan<
  T extends number,
  U extends number,
> = IsNegative<`${T}`> extends true
  ? IsNegative<`${U}`> extends true
    // 둘 다 음수: 절대값이 작은 수가 더 큼 (예: -1 > -2)
    ? GreaterThan_DigitArray<NumberToDigitStringArr<U>, NumberToDigitStringArr<T>>
    // T는 음수, U는 양수 또는 0: 항상 U가 더 큼
    : false
  : IsNegative<`${U}`> extends true
    // T는 양수 또는 0, U는 음수: 항상 T가 더 큼
    ? true
    // 둘 다 양수 또는 0: 일반 비교
    : GreaterThan_DigitArray<NumberToDigitStringArr<T>, NumberToDigitStringArr<U>>;

// 첫 번째 숫자가 두 번째 숫자보다 크거나 같은지 확인하는 타입
type GreaterThanOrEqual<
  T extends number,
  U extends number,
> = `${T}` extends `${U}`
  ? true // 두 숫자가 같으면 true
  : GreaterThan<T, U>; // 다르면 크기 비교 수행

 

실제 IsInRange 타입은 아래와 같다.

type Numeric = number | `${number}`
type NumericToNumber<T extends Numeric> = T extends `${infer N extends number}` ? N : T extends number ? T : never

type IsInRange<Target extends Numeric, Min extends Numeric, Max extends Numeric> =
  GreaterThanOrEqual<NumericToNumber<Target>, NumericToNumber<Min>> extends true
    ? GreaterThanOrEqual<NumericToNumber<Max>, NumericToNumber<Target>> extends true
      ? Target
      : never
    : never

 

 

아래 코드는 IsInRange 타입을 사용한 컴포넌트의 예시이다. 20에서부터 30까지, 그리고 200에서부터 300까지의 숫자만 프로퍼티로 받도록 했고, 그 외의 값을 넣으면 내부적으로 never 처리되어 타입 에러가 발생한다. 그렇다. 하나의 IsInRange 뿐만 아니라 여러 IsInRange를 사용해 범위를 띄엄띄엄 지정해도 문제 없이 처리된다.

export function Test<T extends Numeric>({size}: {size: IsInRange<T, 20, 30> | IsInRange<T, 200, 300>}) {
  return ...
}

 

숫자나 문자로 된 25는 범위 내에 들어 타입 에러를 발생시키지 않았지만, 100은 범위에 들지 않아 never 처리되어 타입 에러가 발생한 것을 확인할 수 있다.

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기