TypeScript's Dark Arts: Building a Type-Level Calculator
TypeScript is its own programming language :3
Introduction
Have you ever wondered if we could do math without runtime? ๐ฑ
Yeah... we're gonna build a little calculator in TypeScript. It's gonna be fun hehe, a bit of a brain teaser. ๐
Weโll do addition, subtraction, division and multiplication.
The Basics: Types as Values
First, let's start with something surprising: TypeScript's type system is Turing complete. This means we can do calculations using just types.
type IsTwo<T> = T extends 2 ? true : false;
type Result = IsTwo<2>; // true
Cute, but boring. Let's get wild.
Building Numbers From Scratch
In type-land, we can represent numbers as tuples of a specific length.
Why? Because we can measure them!
type BuildTuple<
Length extends number,
Result extends Array<unknown> = []
> = Result["length"] extends Length
? Result
: BuildTuple<Length, [...Result, unknown]>;
// BuildTuple<3> = [unknown, unknown, unknown]
// Basically: Give me an array of 'Length' unknown elements
// BuildTuple<5>["length"] = 5 ๐
"But that's just an array!" I hear you say. Yes, but it's an array we can count. And if we can count, we can do math! ๐
Subtraction
Here's where it gets spicy. Want to subtract 3 from 5? Easy! Just:
Build a tuple of length 5
Try to match it against a pattern that has a tuple of length 3
Count what's left (by inferring the rest of the tuple)
type Subtract<
Value extends number,
SubtractAmount extends number
> = BuildTuple<Value> extends [...BuildTuple<SubtractAmount>, ...infer Rest]
? Rest["length"]
: never;
type ThreeMinusTwo = Subtract<3, 2>; // 1
When we do Subtract<5, 3>
, here's the galaxy-brain moment of what happens:
// 1. BuildTuple<5> creates [unknown, unknown, unknown, unknown, unknown]
// 2. BuildTuple<3> creates [unknown, unknown, unknown]
// 3. Pattern matching:
// [unknown, unknown, unknown, unknown, unknown] extends
// [...[unknown, unknown, unknown], ...infer Rest]
// 4. Rest captures [unknown, unknown]
// 5. Rest['length'] = 2 ๐
Addition
Take both numbers, build tuples of the appropriate lengths, and spread them together.
type Add<A extends number, B extends number> = Length
[...BuildTuple<A>, ...BuildTuple<B>]
>
type AddTwoAndThree = Add<2, 3>; // [unknown, unknown, unknown, unknown, unknown]
AddTwoAndThree["length"]; // 5
Multiplication
type Multiply<
A extends number,
B extends number,
Result extends Array<unknown> = []
> = B extends 0
? Length<Result>
: Multiply<A, Subtract<B, 1>, [...Result, ...BuildTuple<A>]>
Multiplication is a bit tricky to understand at first. Let's break it down:
type MultiplyTwoByThree = Multiply<2, 3>; // 6
It's MultiplyTwoByThree
is 6 here and we don't need to use ["length"]
because we're returning Length<Result>
.
The idea is we keep spreading in the tuple of A
until we've done it B
times. If B
extends 0
, it means B
is 0
therefore we return the length of the result.
Let's go over two iterations of MultiplyTwoByThree
. First iteration:
type Multiply<
2,
3,
[]
// 3 extends 0 is false!
> = 3 extends 0
? Length<[]>
// Build a tuple of 2 elements
// Spread it into the result along with the empty array (initial results)
// `B` is now 2 with Subtract<3, 1>
: Multiply<2, Subtract<3, 1>, [...[], ...BuildTuple<2>]>
Second iteration:
type Multiply<
2,
2,
[unknown, unknown]
// 2 extends 0 is false!
> = 2 extends 0
? Length<[unknown, unknown]>
// Build a tuple of 2 elements
// Spread it into the result along with [unknown, unknown]
// `B` is now 1 with Subtract<2, 1>
: Multiply<2, Subtract<2, 1>, [...[unknown, unknown], ...BuildTuple<2>]>
Division
Division is a bit different. Multiplication was repeated addition. Division is repeated subtraction.
type Divide<
A extends number,
B extends number,
Count extends Array<unknown> = []
> = A extends 0
? Length<Count>
: // This is never if `A` is less than `B`
Subtract<A, B> extends never
? Length<Count>
: Divide<Subtract<A, B>, B, [...Count, unknown]>;
How it works is Count keeps increasing the number of times we've subtracted B
from A
until A
is less than B
.
Imagine we have 6 / 2. What you do is you decrement 6 by 2, and every time you do that, you increment a counter. We know it's three, but let's go over it together:
6 - 2 = 4
4 - 2 = 2
2 - 2 = 0
In the fourth iteration, A
is now 0. We return the length of the count, which is 3.
type DivideSixByTwo = Divide<6, 2>; // 3
Let's go over this one together. Two iterations like we did before:
type Divide<
6,
2,
[]
> = 6 extends 0
? Length<[]>
: Subtract<6, 2> extends never
? Length<[]>
: Divide<Subtract<6, 2>, 2, [...[], unknown]>
Second iteration:
type Divide<
4,
2,
[unknown]
> = 4 extends 0
? Length<[unknown]>
: Subtract<4, 2> extends never
? Length<[unknown]>
: Divide<Subtract<4, 2>, 2, [...[unknown], unknown]>
Utility Types
Some utility types that can come in handy:
type Length<T extends Array<unknown>> = T["length"];
type First<T extends Array<unknown>> = T extends [infer First, ...unknown[]]
? First
: never;
type Last<T extends Array<unknown>> = T extends [...infer _, infer Last]
? Last
: never;
// You can do any number!
type Zero = 0;
type One = 1;
type Two = 2;
type Three = 3;
type Four = 4;
type Five = 5;
type Six = 6;
type Seven = 7;
type Eight = 8;
Will I ever need this?
No. Most of the times, if dealing with complicated TypeScript types, it's abstracted away from you by e.g. a library.
However, when building larger systems, you may build your own utilities and abstractions, so knowing TypeScript at a deeper level can be helpful, but not necessary!
The Real Magic
The real magic isn't in what these types do. It's in realizing that TypeScript's type system is like its own programming language. We're not just defining types. We're creating programs that work during compilation.
Our BuildTuple
type works like a recursive function. Our Subtract
type uses pattern matching. We're doing functional programming, but with types!