본문 바로가기

FrontEnd

타입스트립트의 타입시스템으로 산수 구현하기

반응형

원문 주소

Implementing Arithmetic Within TypeScript’s Type System

Use TypeScript’s 4.x releases to implement natural numbers and basic mathematical operators.

itnext.io

타입스크립트!

타입스크립트 4.x 버전 덕택에 지금까지 불가능했던 타입을 표현할 수 있게 되었다.
만약 타입만으로 코드 실행 결과의 검증이 가능하다면 1+2같은 함수의 테스트는 구현할 필요가 없을것이다.
즉 Add<3,5> 타입의 결과가 8이라면, Add<3,5> 타입은 8이외의 값은 허용하지 않을것이다.
타입스크립트의 타입만으로 컴파일 타임 이전에 알 수 있는 코드 실행 결과를 검증해보자.


사용하는 기능

이전부터 존재한 친숙한 기능들

  • 조건부 타입 (conditional types)
    • 3항 연산자 
    • SomeType  ===  AnotherType ? TrueType : FalseType 
    • SomeType extends AnotherType ? TrueType : FalseType
    • (주 : 정말 정확한 타입을 원한다면 extends를 양방향으로 먹여주면 된다 - AnotherType extends SomeType)
  • 타입 추론 (type inference)
    • infer 부분을 지역변수처럼 사용함. 지역변수에 타입을 할당한다고 생각하자.
    • 조건부 타입과 함께 활용되여, 객체 혹은 함수의 내부 (객체 속성, 배열의 값, 함수의 파라미터, 리턴타입)의 타입을 구조분해 할당한다고 생각하면 좋다.
    • type Flatten<T> = T extends (infer U)[] ? U : T
      • Flatten<[number, string, object]>이면 결과는  (number | string | object)
  • 제네릭 제약조건 (generic constraints)
    • 제네릭이 인스턴스화 되길 기대하는 타입을 지정하면서, 제네릭 타입을 만드는 방법.
    • 타입 변수에 해당하는 타입의 인스턴스가 해당 타입이 지정하는 값에 들어오길 기대 
      • type Flatten<T>은 T에 모든 타입의 가능성을 열어둠
      • type Flatten<T extends any[]>는 T가 배열에 할당 가능해야 함을 의미.

4.x 이후에 추가된 기능들은 무엇들이 있을까요?

  • 조건부 타입 재귀(recursion in conditional types)
    • 조건부 타입의 분기 중 하나가 자신을 참조하고 조건부 타입의 논리를 통해 임의의 횟수만큼 재귀하도록 허용합니다. 
    • 4.1 이전 버전에서는 타입 재귀를 사용하려 하면 순환 참조 오류가 발생하였음
  • 가변 길이 튜플(variadic tuples)
    • 튜플 타입 내부에서 spread 연산자를 자유롭게 사용하여, 튜플 타입들을 결합할 수 있습니다.
      • type Booleans = [boolean, boolean]
      • type Derived = [...Booleans, string] = [boolean, boolean, string]
  • 탬플릿 리터럴 타입(template literal types)
    • 템플릿 리터럴 구문을 사용하여 다른 문자열 리터럴 타입에서 문자열 리터럴 타입을 형성합니다.
      • type ClickEvent = 'click' 을 취하여, 이벤트 핸들러 타입 type ClickEventHandler = `on${ClickEvent}`('onclick')를 파생시킬 수 있습니다.

타입 구현 방식

  1. 각 숫자 리터럴을 해당 튜플의 길이에 매핑합니다.
  2. 가변 길이 튜플 기능을 사용하여 튜플을 조작합니다.
  3. 결과 튜플의 길이를 숫자 리터럴에 다시 매핑합니다.

산술 계산의 기반이 튜플 길이이기 때문에

  • 자연수가 아니면 대응이 불가능합니다.
  • 음수, 분수 개념이 없습니다.
우리는 산술 연산의 가장 기본적인 집합만을 구현하기 위해 1-3번 메커니즘을 사용할 것입니다.

구현하기

기본 유틸리티

구현방식의 3번 : 배열(튜플)도 객체이며, 배열(튜플)의 length 속성의 타입(리터럴)을 L에 구조분해 할당한다고 생각합시다.

type Length<T extends any[]> = 
    T extends { length: infer L } ? L : never;


구현 방식의 1번 : 튜플을 숫자로 다시 매핑하기 위해 해당 타입을 배열에 할당할 수 있고 length 속성을 갖고 있다는 사실을 이용합니다.

type BuildTuple<L extends number, T extends any[] = []> = 
    T extends { length: L } ? T : BuildTuple<L, [...T, any]>;
 
한번 사용해 봅시다.
let length: Length<[number, string, string, boolean]>; // `4`
let tuple: BuildTuple<5>; // `[any, any, any, any, any]`

기본 산술 구현

첫 번째 산술 타입인 더하기 및 빼기를 작성할 수 있습니다.

type Add<A extends number, B extends number> = 
    Length<[...BuildTuple<A>, ...BuildTuple<B>]>;
type Subtract<A extends number, B extends number> = 
    BuildTuple<A> extends [...(infer U), ...BuildTuple<B>]
        ? Length<U>
        : never;


Add는 각각 입력 중 하나 길이인 두 개의 튜플을 만들고 결합하여 결과 튜플의 길이를 결정하는 간단한 타입입니다.
Subtract는, A가 3이면, ...BuildTuple<B> 부분이 길이 2가 되므로, U는 BuildTuple<1>이 되어 [any] => Length<[any]> => 1이 됩니다.
Subtract가 제대로 동작하려면 A가 B보다 크거나 같아야 합니다.

type a =Subtract<0,0> // 0 
let five: Add<3, 2>; // `5`
let one: Subtract<3, 2>; // `1`

좀 더 복잡한 유틸리티 타입

곱하기 및 나누기를 위한 유형을 작성하려면 현재 가지고 있는 것보다 더 강력한 유틸리티 타입이 필요합니다.
곱셈은 ​​곱셈의 반복이기 때문에 Add 타입의 여러 반복 결과를 누적할 수 있는 타입이 필요합니다.
type MultiAdd<
    N extends number, A extends number, I extends number
> = I extends 0 ? A : MultiAdd<N, Add<N, A>, Subtract<I, 1>>;
제네릭은 반복 덧셈 횟수 N, 누적값을 기억하는 A, 남은 반복횟수를 기억하는 I로 구성됩니다.
예를 들어 8*3은 8을 3번 더하는 것을 의미합니다.
type b = MultiAdd<8,0,3> // 24
(유클리드) 나눗셈은 뺄셈을 반복적으로 사용하는 것과 유사하므로 이를 처리하는 유사한 유틸리티를 구축할 수 있습니다.
그러나 A가 B보다 작을 때에는 반복되는 빼기 루틴을 종료해야 하므로 구현에는 추가 유틸리티가 필요합니다.
type EQ<A, B> =
    A extends B
        ? (B extends A ? true : false)
        : false;
        
type AtTerminus<A extends number, B extends number> = 
    A extends 0
        ? true
        : (B extends 0 ? true : false);
        
type LT<A extends number, B extends number> = 
    AtTerminus<A, B> extends true
        ? EQ<A, B> extends true
            ? false
            : (A extends 0 ? true : false)
        : LT<Subtract<A, 1>, Subtract<B, 1>>;
        
type MultiSub<
    N extends number, D extends number, Q extends number
> = LT<N, D> extends true
    ? Q
    : MultiSub<Subtract<N, D>, D, Add<Q, 1>>;​

분해해서 알아봅시다.

EQ는 단순히 두 가지 타입이 서로에게 할당할 수 있는지 확인합니다.
그렇다면 그들은 같은 타입이어야 하므로 우리는 그들이 같다고 결론을 내립니다.
즉, EQ<1, 3>은 거짓이고 EQ<3, 3>은 참입니다.
 
AtTerminus는 A 또는 B가 0인지 확인하고 그렇다면 true를 리턴합니다.
이것은 나중에 A와 B에서 뺄 때 0을 지나 반복하지 않도록 합니다.
(둘 중 하나를 음수로 만들지 않기 위함입니다.)
 
LT는 Less than (A는 B보다 작은가?)를 의미합니다.
종료 지점에 도달했는지 확인하고(A 또는 B 또는 둘 다 0임을 의미)
그렇지 않은 경우 각 입력에서 반복적으로 1을 뺍니다.
 
종료 지점에 도달하면 A와 B가 동일한지 확인합니다.
동등하다는 것은 A가 B보다 작지 않음을 의미하므로(4는 4보다 작지 않으므로) false를 반환합니다. (나눗셈 한번 더 가능)
그러나 그것들이 같지 않으면 그들 중 하나만 0이 되므로 A가 0이면 true를 반환합니다.
B가 0인 경우 A가 B보다 크다는 것을 의미하므로 한번 더 나눌 수 있습니다.
 
 
MultiSub는 숫자 N, 제수 D( Divisor), 각 반복에서 N에서 D를 뺀 횟수를 나타내는 몫 Q (Quotient)를 취합니다. N이 D보다 작아지면 빼기가 중지되고  Q를 반환합니다. (뺀 횟수로 나눗셈을 나타내었으니 재귀 횟수가 몫이 됩니다.)
 
예를 들어, 8 / 3을 수행하면 N과 D에 각각 8과 3이 할당되어야 함을 의미합니다.
Q는 0에서 시작하고(MultiSub의 첫 번째 반복에 있을 때 아무 것도 빼지 않았기 때문에)
8에서 3을 빼며, LT 검사가 참이 될 때까지 Q를 1씩 증가시킵니다.
Q는 나눗셈의 몫을 나타냅니다.
type c = MultiSub<8,3,0> // 2

좀 더 복잡한 산술 타입

이제 곱셈과 나눗셈 타입을 구현할 수 있습니다.
대부분의 작업이 유틸리티에서 수행되기 때문에 이러한 작업은 매우 간단합니다.

// 곱셈
type Multiply<A extends number, B extends number> = 
    MultiAdd<A, 0, B>;

// 몫
type Divide<A extends number, B extends number> = 
    MultiSub<A, B, 0>;
    
// 나머지
type Modulo<A extends number, B extends number> = 
// A가 B보다 작으면
    LT<A, B> extends true ? A : Modulo<Subtract<A, B>, B>;


Modulo에는 유틸리티가 필요하지 않습니다.
재귀 호출을 통해 반복한 횟수가 아니라 반복된 뺄셈 후에 남은 것만 알면 됩니다.

let twentyEight: Multiply<7, 4>; // `28`
let one: Divide<7, 4>; // `1`
let three: Modulo<7, 4>; // `3`


입력이 자연수인지 확인

지금까지 제네릭 유형 제약 조건은 입력이 자연수가 아닌 number 타입이어야 함을 의미합니다.

그런데, 자연수 이외의 값을 사용하여 모든 종류의 산술을 수행하려고 하면 컴파일 오류가 발생합니다.
그것도 최대 재귀 깊이 제한 오류입니다.

Type instantiation is excessively deep and possibly infinite.(2589)

이것은 우리가 해당 연산의 구현에 사용한 튜플의 길이가 불연속적이고 양수인 것에서 기인합니다.

let one: Add<3, -2>;
let onePointSeven: Subtract<3, 1.3>;

type BuildTuple<L extends number, T extends any[] = []> = 
    T extends { length: L } ? T : BuildTuple<L, [...T, any]>;

type Add<A extends number, B extends number> = 
    Length<[...BuildTuple<A>, ...BuildTuple<B>]>;
    
type Length<T extends any[]> = 
    T extends { length: infer L } ? L : never;

type Subtract<A extends number, B extends number> = 
    BuildTuple<A> extends [...(infer U), ...BuildTuple<B>]
        ? Length<U>
        : never;

-2는 length : L에서 L(0 이상만 가능)이 절대 될수 없기에 계속 -2를 전달하여 무한 재귀를 발생시킵니다.
1.3도 마찬가지입니다.

이 문제를 해결하는 방법은 TypeScript의 새로운 템플릿 리터럴 타입을 사용하는 것입니다.
우리의 목표는 입력 패턴이 음수 또는 십진수와 일치하는지 확인하는 것입니다.
따라서 이러한 각 상황을 확인하기 위해 타입을 생성해 보겠습니다.

type IsPositive<N extends number> = 
    `${N}` extends `-${number}` ? false : true;
type IsWhole<N extends number> = 
    `${N}` extends `${number}.${number}` ? false : true;

이러한 제네릭 타입은 모두 어떠한 타입 N으로 인스턴스화됩니다.
(제네릭 제약으로 인해 number여야 하고 템플릿 리터럴 타입으로 보간할 수 있음).
그런 다음 N에서 파생된 템플릿 리터럴 타입을 특정 타입 패턴과 비교합니다.
 
첫번째 패턴은 양수 여부를
두번째 패턴은 .과 . 뒤의 숫자를 포함하지 않는지를 판별합니다.

이중 부정 :
 
템플릿 리터럴 타입 -${number}는 원하는 대로 -1과 일치하지만 --1과도 일치합니다.
그 이유는 1과 -1 모두 숫자에 할당할 수 있기 때문에 템플릿 리터럴에서 number를 사용할 때 결과 템플릿 리터럴 타입이 컨텍스트에서 의미가 있는지 여부에 관계없이 기본적으로 가능한 모든 숫자에 빼기 기호를 추가합니다.
 
다중 소수 : 
 
유사하게, 우리의 십진법 패턴은 1.3.1과 같은 것과 일치할 것입니다.
왜냐하면 1.3과 1은 모두 숫자에 할당할 수 있고, 우리 패턴은 그들 사이에 순진하게 소수점을 추가하기 때문입니다.
 
다중 소수와 이중 부정 문제가 존재하지만, 우리가 원하는 조건을 체크하는데는 충분합니다. (좀 더 엄격하기 때문)

let negativeNumber: IsPositive<-13>; // `false`
let positiveNumber: IsPositive<5>; // `true`
let float: IsWhole<1.3>; // `false`
let round: IsWhole<13>; // `true`
 
이제 숫자가 유효한지 확인하기 위해 이 타입들을 사용할 수 있습니다.
type IsValid<N extends number> = 
    IsPositive<N> extends true
        ? (IsWhole<N> extends true ? true : false)
        : false;
type AreValid<A extends number, B extends number> = 
    IsValid<A> extends true
        ? (IsValid<B> extends true ? true : false)
        : false;
 
AreValid 타입을 사용해 입력이 유효하지 않은 경우 never를 리턴하는 조건부 타입으로 래핑하여
모든 산술 연산의 안전한 버전을 생성할 수 있습니다.
type SafeAdd<A extends number, B extends number> = 
    AreValid<A, B> extends true ? Add<A, B> : never;
type SafeSubtract<A extends number, B extends number> = 
    AreValid<A, B> extends true ? Subtract<A, B> : never;
type SafeMultiply<A extends number, B extends number> = 
    AreValid<A, B> extends true ? Multiply<A, B> : never;
type SafeDivide<A extends number, B extends number> = 
    AreValid<A, B> extends true ? Divide<A, B> : never;
type SafeModulo<A extends number, B extends number> = 
    AreValid<A, B> extends true ? Modulo<A, B> : never;
let sum: Add<3, 4>; // `7`
let badSum: Add<3, 4.5>; // `never`
let anotherBadSum: Add<3, -4>; // `never`

지금까지 구현한 대수 타입 시스템의 한계

첫째, 타입 재귀 깊이에는 제한이 존재합니다. 따라서, 모든 자연수 범위를 표현할 수 는 없습니다.
둘째, 재귀 조건이 더 복잡해짐에 따라 계산 비용도 많이 들 수 있습니다.
너무 크고 복잡한 재귀 타입은 컴파일러를 느리게 할 수 있습니다.
버전 4.1의 릴리스 정보에서도 성능 저하를 방지하기 위해 이 기능을 드물게 사용해야 한다고 말합니다.
셋째, Subtract 구현의 경우 제약 조건이 있었습니다. 해당 타입에 대해 잘못된 입력을 완전히 방지할 수 없음을 받아드려야 합니다.
(탬플릿 리터럴 타입을 잘 활용하면 극복 가능할 것 같음...)
 

마무리

더 흥미로운 주제들이 있습니다 : 소수 만들기, 상대 소수 만들기, 몫으로 divisor 찾기 등을 구현해 보세요
위 예제들을 타입스크립트 플레이그라운드에서 직접 경험해 보기


비슷하지만 더 진보한 구현들을 보고싶다면...
https://dev.to/phenomnominal/i-need-to-learn-about-typescript-template-literal-types-51po

I need to learn about TypeScript Template Literal Types

It's Sunday in New Zealand and I don't want to get out of bed yet, so instead I'm going to listen to...

dev.to

반응형