Introduction of Template Literal Types in TypeScript 4.1 makes the type system even more interesting. They are like template literals, but on the type level (a template literal type with a placeholder), so we can write code like this:

type SizeUnit = 'px' | 'pt' | 'em' | 'rem'

type Size = `${number}${SizeUnit}`;
const width: Size = '10px';

More on that here and here.

We can use this, along with other features (including recursive conditional types), to perform mathematical operations on literals representing natural numbers. I’ll assume that the numbers are represented as string literal types. We can always convert from number like this:

type NumToStr<N extends number> = `${N}`;

Manipulating digits

Digits are defined as a simple union. The most basic operations will be increasing or decreasing a digit by one.

type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";

interface Incr extends Record<Digit, Digit> {
    "0": "1";
    "1": "2";
    "2": "3";
    "3": "4";
    "4": "5";
    "5": "6";
    "6": "7";
    "7": "8";
    "8": "9";
};

interface Decr extends Record<Digit, Digit> {
    "1": "0";
    "2": "1";
    "3": "2";
    "4": "3";
    "5": "4";
    "6": "5";
    "7": "6";
    "8": "7";
    "9": "8";
};

type Two = Incr["1"];

Carry will be represented as "0" or "1" string and result of digit addition will be represented as a pair:

type Carry = "0" | "1";

type AddDigitsRes<C extends Carry, D extends Digit> = [C, D];

Along with the AddDigits operator, we’ll define auxiliary AddDigitsC and AddDigitsC2 operators which will help us handle the carried numbers.

type AddDigits<D1 extends Digit, D2 extends Digit> = AddDigitsC2<D1, D2, "0", "0">;

type AddDigitsC<D1 extends Digit, D2 extends Digit, C extends Carry> = AddDigitsC2<D1, D2, C, "0">;

type AddDigitsC2<D1 extends Digit, D2 extends Digit, CPrev extends Carry, CNxt extends Carry> =
    D1 extends "0"
    ? (CPrev extends "0" ? AddDigitsRes<CNxt, D2> : AddDigitsC2<CPrev, D2, "0", CNxt>)
    : D2 extends "9" ? AddDigitsC2<Decr[D1], CPrev, "0", "1">
        : AddDigitsC2<Decr[D1], Incr[D2], CPrev, CNxt>;

CPrev is the carry from previous computation (say, addition of digits from previous column). I added CNxt for convenience to represent the carry of the current operation. The idea is pretty simple:

type AddDigitsCTest = AddDigitsC<"4", "5", "1"> // = ["1", "0"]

Chopping off digits

The general idea is to extract the last digits, add them, save the result and repeat the process on the prefixes (with last digits chopped off). Alternatively we could convert each number to a list of digits first, then perform addition on such a representation.

To do this, we need to define three auxiliary operators first:

type Prefix<N extends string> = N extends `${infer P}${Digit}` ? P : never;

type Ten = Prefix<"102">;

We simply ask TypeScript to infer the prefix by stating in the condition that it’s followed by any digit. With Last we do this again, then use the inferred prefix to infer the actual digit:

type Last<N extends string> = N extends `${infer P}${Digit}`
    ? N extends `${P}${infer D}`
        ? D extends Digit ? D : never
        : never
    : never;

type Six = Last<"256">;

The condition D extends Digit ? D : never might seem redundant, but this way we enforce that the operator actually returns a Digit (and not simply a string), which will be needed later. Typescript doesn’t remember that the inferred D is the same Digit from the condition above.

The definition for Empty is straightforward:

type Empty<S extends string> = S extends '' ? true : false;

Basic operations

The logic behind addition:

type AddAux<N1 extends string, N2 extends string, C extends Carry, R extends string> =
    Empty<N1> extends true
    ? Empty<N2> extends true 
        ? (C extends "0" ? R : `${C}${R}`) // both numbers empty
        : AddAux<C, N2, "0", R> // first number empty
    : Empty<N2> extends true
        ? AddAux<N1, C, "0", R> // second number empty
        // add last digits and move on
        : AddDigitsC<Last<N1>, Last<N2>, C> extends AddDigitsRes<infer C1, infer DSum>
            ? AddAux<Prefix<N1>, Prefix<N2>, C1, `${DSum}${R}`>
            : never;

Note how we use conditional types to obtain the result:

AddDigitsC<Last<N1>, Last<N2>, C> extends AddDigitsRes<infer C1, infer DSum>

It’s like saying “add the digits and assign the result to new variables C1 and DSum”.

For subtraction we don’t allow the minuend to be smaller than the subtrahend, so the logic is:

type SubAux<N1 extends string, N2 extends string, C extends Carry, R extends string> =
    Empty<N1> extends true
    ? C extends "1"
        ? never
        : Empty<N2> extends true
            ? R
            : never
    : Empty<N2> extends true
        ? C extends "1"
            ? SubAux<N1, C, "0", R>
            : `${N1}${R}`
        : SubDigitsC<Last<N1>, Last<N2>, C> extends SubDigitsRes<infer C1, infer DDiff>
            ? SubAux<Prefix<N1>, Prefix<N2>, C1, `${DDiff}${R}`>
            : never

I omit implementation of the auxiliary operators on digits because it’s very similar to addition. Now we want to wrap the auxiliary addition and subtraction operators in more user-friendly operators:

type Add<N1 extends string, N2 extends string> = AddAux<N1, N2, "0", "">;
type Sub<N1 extends string, N2 extends string> =
    SubAux<N1, N2, "0", ""> extends infer U ? U extends string ? RemoveZeros<U> : never : never;
type RemoveZeros<N extends string> =
    N extends "0" ? "0" : N extends `0${infer S}` ? RemoveZeros<S> : N;

It turns out that there might be leading zeros after subtraction, so we remove them.

And here we go - we can add or subtract quite long numbers and the type deduction is very fast.

type AddTest = Add<"12345678546657", "1234567890768769876764">; // 1234567903114448423421

Other operations

What about other operators? Multiplication is very easy to implement using already defined operators, so I leave it as an exercise. Mod is a little bit more interesting:

type Mod<N1 extends string, N2 extends string> = Sub<N1, N2> extends infer Diff
    ? IsNever<Diff> extends true
        ? N1
        : Diff extends "0"
            ? "0"
            : Diff extends string
                ? Mod<Diff, N2>
                : never
    : never;

How do we check if a given type is never? It turns out, we need to do it like this:

type IsNever<T> = [T] extends [never] ? true : false

The question is, why can’t we just say T extends never ?? If you have trouble figuring this out, you’ll find the answer in the links below.

Caveats

There are a few things that need to be mentioned at the end. As I noted at the beginning, we can always wrap the operators so that we work on number literals instead of string literals, like this:

type AddN<N1 extends number, N2 extends number> = Add<IntToStr<N1>, IntToStr<N2>>;

However, it leads to Numeric literals with absolute values equal to 2^53 or greater are too large to be represented accurately as integers problem. In the case of larger numbers, this generates very weird results.

Secondly, although add/sub operations are quite reliable, operations like mul/mod are much more complex (unless we take a completely different approach) and thus they won’t work on long numbers (in fact I run into Type instantiation is excessively deep and possibly infinite problem with a multiplicand of just 1000).

Thirdly, you might argue that digit addition could be implemented in a better way - instead of increasing/decreasing by one - using a hard-coded table for each combination. Something like this:

type AddT<D1 extends Digit, D2 extends Digit> = {
    "0": ["0", D2];
    "1": { "0": ["0", "1"]; "1": ["0", "2"]; "2": ["0", "3"]; ... "9": ["1", "0"]; }[D2];
    "2": { "0": ["0", "2"]; "1": ["0", "3"]; "2": ["0", "4"]; ... "9": ["1", "1"]; }[D2];
    ...
    "9": { "0": ["0", "9"]; "1": ["1", "0"]; "2": ["1", "1"]; ... "9": ["1", "8"]; }[D2];
}[D1];

type AddTTest = AddT<"4", "8">; // ["1", "2"]

Looks nice and fast, the implementation of AddDigitsC2 would be simplified, but surprisingly, I haven’t noticed any meaningful improvement over the previous implementation.

Lastly, without the possibility to use recursive calls in conditional types, the implementations would be so much more ugly and painful.


Links: