Right, so you want to do arithmetic at the type level. You’re probably thinking, “Why on earth would I need that?” Trust me, I had the same thought. The answer, as it so often is in TypeScript, is: because you’ve painted yourself into a very specific corner and this is the only way out. We’re not building general-purpose calculators here. This is about proving things to the type system—like the exact length of a tuple, or that you’ve split an array into two chunks of precisely equal size. It’s a parlor trick, but a damn useful one when you need it.

The core idea is brilliantly, almost stupidly, simple. We represent numbers not as number, but as types themselves—specifically, as recursive tuple types. Think of it like tally marks. The number 0 is represented by an empty tuple. The number 1 is a tuple with one element, 2 is a tuple with two elements, and so on.

We start by defining a base case. Let’s call our number type Num. Zero is our foundation.

type Zero = { isZero: true };

Okay, that’s not a tuple, bear with me. This representation makes the next part clearer. Now, how do we define a successor? How do we say “one more than” a given number? We create a new type that wraps around it.

type Succ<N> = { isZero: false; prev: N };

The Succ (for “successor”) type is a container. It says, “I am not zero, and the number that came immediately before me was N.” So the number 1 is Succ<Zero>. The number 2 is Succ<Succ<Zero>>. You get the picture. It’s a linked list at the type level.

Adding Two Numbers Together

This is where it gets fun. Adding two type-level numbers, A and B, is a recursive operation. We’re going to use conditional types to break it down.

  1. Base Case: If B is Zero, then A + B = A. Easy.
  2. Recursive Case: If B is not zero, then it must be a successor: Succ<PrevB>. Therefore, A + B is the same as the successor of A + PrevB. We’re essentially taking one tally mark from B and adding it to A until B is zero.

Let’s codify this madness.

type Add<A, B> = B extends Zero
  ? A
  : B extends Succ<infer PrevB>
  ? Succ<Add<A, PrevB>>
  : never;

// Let's test it. First, define our numbers.
type One = Succ<Zero>;
type Two = Succ<One>; // Succ<Succ<Zero>>
type Three = Succ<Two>;

// Now, add One + Two
type OnePlusTwo = Add<One, Two>;
// This resolves to:
// Succ<Add<One, One>>
// Succ<Succ<Add<One, Zero>>>
// Succ<Succ<One>>
// Which is Succ<Succ<Succ<Zero>>>... a.k.a Three.

The infer keyword here is crucial. It lets us peer inside the Succ type and extract the PrevB type so we can recurse with it.

Actually Using This for Tuple Lengths

The tally-mark analogy is fine, but we started with tuples. Let’s connect it back. We can create a type that generates a tuple of a specific length. This is incredibly useful for validating the length of function arguments or mapped types.

First, let’s redefine our numbers as tuples. It’s more practical.

type Zero = [];
type Succ<N extends any[]> = [any, ...N];

type One = Succ<Zero>; // [any]
type Two = Succ<One>;   // [any, any]
type Three = Succ<Two>; // [any, any, any]

Now, let’s write a Length type that gets the length of a tuple. This is trivial with TypeScript’s built-in ['length'] property lookup, but it shows the concept.

type Length<T extends any[]> = T['length'];

type LengthOfTwoTuple = Length<Two>; // type LengthOfTwoTuple = 2

The real power comes from comparing lengths. Let’s write a Slice type that takes a tuple T and a Length L and returns a tuple with the first L elements of T. We’ll need our type-level arithmetic.

type Slice<T extends any[], L extends number, Result extends any[] = []> =
  Length<Result> extends L
    ? Result // Base case: our result is the desired length
    : T extends [infer First, ...infer Rest]
      ? Slice<Rest, L, [...Result, First]> // Recurse, adding one element to the result
      : Result; // If T is empty, just return what we have

type MyTuple = [1, 2, 3, 4, 5];
type FirstThree = Slice<MyTuple, 3>; // type FirstThree = [1, 2, 3]

This works because we’re recursively building the Result tuple and checking its length against the target L at each step. The moment they match, the recursion stops.

The Rough Edges and Pitfalls

This is not a clean, efficient system. It’s a hack. Embrace that.

  1. Performance: Deep recursion (usually beyond ~50 iterations) will make the TypeScript compiler cry. You’ll hit instantiation depth errors. This is why this technique is reserved for small, countable things, not for calculating the 100th Fibonacci number at the type level (yes, people have tried, and yes, it breaks).
  2. Readability: The emitted types are monstrous. Succ<Succ<Succ<Zero>>> is not a friendly error message. Your colleagues might revolt.
  3. It’s Meta: You’re not working with actual data. You’re working with types that describe the shape of data. This is a head-trip when you first start, and it’s easy to get lost in the recursion.
  4. Best Practice: Use it sparingly. This is a tool of last resort. If you can solve a problem with a simple array.length check at runtime, for the love of all that is holy, do that instead. This is for situations where a runtime check is too late and you need the compiler to guarantee correctness now.

So there you have it. Type-level arithmetic: it’s weird, it’s limited, and it feels like you’re building a cathedral out of toothpicks. But when it’s the right tool for the job, nothing else comes close. You’re not just writing types anymore; you’re writing logic. And that’s a powerful, if slightly absurd, place to be.