Solving Advent of Code with only TypeScript types

by Brian Simon ()

Advent of Code publishes a programming puzzle each day from December 1 through 25. We’ll solve 2022’s Day 1 entirely in TypeScript’s type system. The input goes into a type alias and the answer appears in a hover tooltip. No runtime code is required.

The puzzle

The input lists the Calorie count of each food item carried by a group of Elves, one item per line. Blank lines separate their inventories:

1000
2000
3000

4000

5000
6000

7000
8000
9000

10000

This describes five Elves carrying 6000, 4000, 11000, 24000, and 10000 total Calories. Part 1 asks for the total carried by the Elf with the most Calories. For this example, the answer is 24000.

A runtime solution is a few lines:

const answer = Math.max(
  ...input
    .split('\n\n')
    .map((group) =>
      group.split('\n').reduce((total, line) => total + Number(line), 0)
    )
);

We need to do the same three operations with types:

  1. Split the input into groups, then split each group into lines.
  2. Sum the values in each group.
  3. Find the maximum total.

Parsing the input

We need a type-level version of String.prototype.split(). The infer keyword inside a template literal type can match the first separator and capture the strings on either side:

type Split<S extends string, Separator extends string> =
  S extends `${infer Head}${Separator}${infer Rest}`
    ? [Head, ...Split<Rest, Separator>]
    : [S];

type lines = Split<'1000\n2000\n3000', '\n'>;
type lines = ["1000", "2000", "3000"]
Try

Each match removes the first segment, then the tuple spread prepends it to the recursive result for the rest of the string.

Tail recursion

This works for the example, but it fails on the full puzzle input. Each recursive call occurs inside a tuple spread, so TypeScript can’t evaluate the outer call until the inner call resolves. The evaluation stack grows with every segment. At about 50 levels, TypeScript reports "Type instantiation is excessively deep and possibly infinite". My puzzle input contains more than 2,000 lines, so we’d reach that limit after only a few Elves.

Since TypeScript 4.5, the compiler performs tail-recursion elimination on conditional types. If the recursive call is the entire result of a conditional branch, TypeScript evaluates it in a loop instead of on the stack. The limit increases from about 50 nested instantiations to 1,000 iterations. We can take advantage of this by moving the partial result into an accumulator parameter:

type Split<S extends string, Separator extends string, Acc extends string[] = []> =
  S extends `${infer Head}${Separator}${infer Rest}`
    ? Split<Rest, Separator, [...Acc, Head]>
    : [...Acc, S];

type lines = Split<'1000\n2000\n3000', '\n'>;
type lines = ["1000", "2000", "3000"]
Try

The result is unchanged, but the recursive call is now in tail position and can process a few thousand lines. We’ll use this accumulator pattern for every recursive type in the post.

With Split working, parsing the full input takes two passes: split on blank lines (\n\n) to get one string per Elf, then split each string on newlines to get the individual Calorie counts.


type ParseInput<S extends string> =
  Split<S, '\n\n'> extends infer Groups extends string[]
    ? { [K in keyof Groups]: Split<Groups[K], '\n'> }
    : never;

type ExampleInput = `1000
2000
3000

4000

5000
6000

7000
8000
9000

10000`;

type parsed = ParseInput<ExampleInput>;
type parsed = [["1000", "2000", "3000"], ["4000"], ["5000", "6000"], ["7000", "8000", "9000"], ["10000"]]
Try

The extends infer Groups extends string[] clause acts like a local variable. It binds the outer Split result to Groups. The second extends string[], an inline infer constraint, defines the result’s shape so that TypeScript can index it.

Applying a mapped type to a tuple produces another tuple. Mapping Split<..., '\n'> over Groups therefore produces a tuple of tuples.

The input is parsed, but Part 1 asks us to find the Elf carrying the most Calories. To answer that, we need to sum each inventory, then compare the totals. There’s a problem: TypeScript types have neither + nor >.

Type-level addition

We’ll start by building addition.

A + B is a syntax error in a type position. We do have one way to count, though: the length property of a tuple is a number literal type, not just number.

type length = ['a', 'b', 'c']['length'];
type length = 3
Try

If we construct a tuple with N elements, its length is the literal type N. That gives us an addition algorithm: build tuples of lengths A and B, concatenate them, and read the combined length.

type UnaryTuple<N extends number, Acc extends 1[] = []> =
  Acc['length'] extends N ? Acc : UnaryTuple<N, [...Acc, 1]>;

type three = UnaryTuple<3>;
type three = [1, 1, 1]
type NaiveAdd<A extends number, B extends number> = [...UnaryTuple<A>, ...UnaryTuple<B>]['length']; type five = NaiveAdd<2, 3>;
type five = 5
Try

This is unary arithmetic, like counting on your fingers. UnaryTuple is tail-recursive and works for small numbers.

Hitting the recursion limit

Our Elves are carrying items with Calorie counts in the thousands. Counting to 7000 on your fingers takes 7000 iterations, and tail-recursive types get only 1000:


type uhOh = NaiveAdd<7000, 8000>;
Type instantiation is excessively deep and possibly infinite.2589
Type instantiation is excessively deep and possibly infinite.
Try

No amount of accumulator cleverness fixes this, because the iteration count is the magnitude of the number itself. We need an algorithm whose cost scales with the number of digits, not the number of fingers.

Add by columns

Column addition scales by digit count: align the numbers, add each column from right to left, and carry when needed.

  ¹ ¹ ¹    (carries)
    7 8 9
  + 2 3 4
  -------
  1 0 2 3

Column addition gives us exactly what we need. Each operation adds only two digits and a carry, so the largest possible column total is 9 + 9 + 1 = 19. Unary counting can’t reach 7000 within the recursion limit, but it can reach 19.

Split already returns strings, so we’ll process each number as a string of digit characters.

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

/** Digit character -> a tuple of that length, for counting. */
type Units = {
  '0': []; '1': [1]; '2': [1, 1]; '3': [1, 1, 1]; '4': [1, 1, 1, 1];
  '5': [1, 1, 1, 1, 1]; '6': [1, 1, 1, 1, 1, 1]; '7': [1, 1, 1, 1, 1, 1, 1];
  '8': [1, 1, 1, 1, 1, 1, 1, 1]; '9': [1, 1, 1, 1, 1, 1, 1, 1, 1];
};
type CarryUnits = { '0': []; '1': [1] };

/** Every possible column total (indexed from 0..19) -> [digit out, carry out]. */
type CarryTable = [
  ['0', '0'], ['1', '0'], ['2', '0'], ['3', '0'], ['4', '0'],
  ['5', '0'], ['6', '0'], ['7', '0'], ['8', '0'], ['9', '0'],
  ['0', '1'], ['1', '1'], ['2', '1'], ['3', '1'], ['4', '1'],
  ['5', '1'], ['6', '1'], ['7', '1'], ['8', '1'], ['9', '1'],
];

type AddDigits<A extends Digit, B extends Digit, C extends Carry> =
  CarryTable[[...Units[A], ...Units[B], ...CarryUnits[C]]['length'] & number];

type fivePlusThree = AddDigits<'5', '3', '0'>;
type fivePlusThree = ["8", "0"]
type sevenPlusEightPlusCarry = AddDigits<'7', '8', '1'>;
type sevenPlusEightPlusCarry = ["6", "1"]
Try

Units converts a digit character into a tuple of that length, so [...Units[A], ...Units[B], ...CarryUnits[C]]['length'] computes A + B + C using the same length trick as before, just capped at 19. That total then indexes into CarryTable, a 20-entry lookup table where entry n holds the pair [n % 10, n >= 10 ? '1' : '0']: the digit to write down and the carry to pass along.

The & number intersection looks redundant, but it’s necessary while A, B, and C are unresolved type parameters. At that point, TypeScript can’t prove that the computed length is one of the literal indices from 0 through 19. Intersecting the length with number produces a valid index type. Once we pass in concrete digits, the length resolves to a specific literal and the lookup returns one entry.

Walking the columns

Column addition runs from right to left, but template literal matching removes characters from the left. We can work around that by reversing the digits first, so index 0 becomes the ones column:


type ToReversedDigits<S, Acc extends Digit[] = []> =
  S extends `${infer Head extends Digit}${infer Rest}`
    ? ToReversedDigits<Rest, [Head, ...Acc]>
    : Acc;

type reversed = ToReversedDigits<'789'>;
type reversed = ["9", "8", "7"]
Try

Prepending each matched digit to the accumulator reverses their order as we traverse the string.

Now we can process both digit lists together. For each column, we call AddDigits, prepend the resulting digit to a string accumulator, and pass the carry to the next column. Prepending each result restores the original digit order.

If one number is shorter, the loop treats its missing columns as '0'. After both lists are exhausted, the loop emits a final '1' if a carry remains. The HeadDigit and TailDigits helpers provide the default values '0' and []:


type HeadDigit<Digits extends Digit[]> =
  Digits extends [infer Head extends Digit, ...Digit[]] ? Head : '0';

type TailDigits<Digits extends Digit[]> =
  Digits extends [Digit, ...infer Tail extends Digit[]] ? Tail : [];

type AddDigitLists<A extends Digit[], B extends Digit[], C extends Carry = '0', Acc extends string = ''> =
  [A, B] extends [[], []]
    ? (C extends '1' ? `1${Acc}` : Acc)
    : AddDigits<HeadDigit<A>, HeadDigit<B>, C> extends [infer D extends Digit, infer C2 extends Carry]
      ? AddDigitLists<TailDigits<A>, TailDigits<B>, C2, `${D}${Acc}`>
      : never;

type AddStr<A, B> =
  AddDigitLists<ToReversedDigits<A>, ToReversedDigits<B>>;

type easy = AddStr<'789', '234'>;
type easy = "1023"
type carrying = AddStr<'999', '1'>;
type carrying = "1000"
type uneven = AddStr<'5', '99999'>;
type uneven = "100004"
Try

The recursion depth now matches the digit count, so adding five-digit Calorie counts takes five iterations instead of tens of thousands. A wrapper accepts number literals, converts them to strings, and converts the result back with a type-level parseInt:


type ParseInt<S extends string> =
  S extends `${infer N extends number}` ? N : never;

type Add<A extends number, B extends number> = ParseInt<AddStr<`${A}`, `${B}`>>;
Expression produces a union type that is too complex to represent.2590
Expression produces a union type that is too complex to represent.
Try

Avoiding a union explosion

There’s one more problem: the declaration fails before we even instantiate Add.

Here’s what’s happening. To verify that AddStr<...> satisfies the S extends string constraint on ParseInt, TypeScript evaluates the addition types while A and B are still unknown. It substitutes each type parameter’s constraint for a concrete value. Under these conditions, HeadDigit falls back to the full Digit union of all ten digits.

Adding two ten-digit unions produces a larger union. Each iteration of AddDigitLists then multiplies the accumulated template literal union by another factor of ten. After a few columns, the union exceeds TypeScript’s limit of 100,000 members.

We can defer that evaluation by removing the S extends string constraint from ParseInt. Without the constraint, TypeScript doesn’t need to prove that the result is a string when it checks the declaration. The addition loop remains unevaluated until it receives concrete digits:


type ParseInt<S> =
  S extends `${infer N extends number}` ? N : never;

type Add<A extends number, B extends number> = ParseInt<AddStr<`${A}`, `${B}`>>;

type works = Add<7000, 8000>;
type works = 15000
type alsoWorks = Add<999, 1>;
type alsoWorks = 1000
Try

For the same reason, we leave the parameters on AddStr and ToReversedDigits unconstrained.

The unconstrained parameters weaken errors at the call site. For example, passing a boolean to AddStr produces never later instead of reporting the invalid argument immediately. This solver passes only digit strings, so that limitation is acceptable. The recursive call remains in tail position and still receives tail-recursion elimination.

Add<7000, 8000> now evaluates successfully.

Type-level comparison

To find the Elf with the most Calories, we need to compare two number literals. We’ll build a three-way comparator that returns 'gt', 'lt', or 'eq', because “equal” and “less than” need to be distinguishable in the middle of a digit-by-digit walk.

We’ll start by comparing single digits with the Units table. If A’s tuple contains all of B’s tuple and has elements remaining, then A is greater.


type CompareDigits<A extends Digit, B extends Digit> =
  A extends B
    ? 'eq'
    : Units[A] extends [...Units[B], ...1[]]
      ? 'gt'
      : 'lt';

type gt = CompareDigits<'7', '3'>;
type gt = "gt"
type lt = CompareDigits<'2', '6'>;
type lt = "lt"
type eq = CompareDigits<'4', '4'>;
type eq = "eq"
Try

Units[A] extends [...Units[B], ...1[]] checks whether A’s tuple starts with all of B’s tuple. This is true if A >= B. Because the first branch handles equality, this branch means A > B.

For non-negative integers without leading zeros, the number with more digits is greater. We can compare lengths by removing one character from each string per iteration. The number whose string is exhausted first is smaller.

type CompareLengths<A extends string, B extends string> =
  A extends `${string}${infer ARest}`
    ? B extends `${string}${infer BRest}`
      ? CompareLengths<ARest, BRest>
      : 'gt'
    : B extends `${string}${infer BRest}`
      ? 'lt'
      : 'eq';

type longer = CompareLengths<'100', '99'>;
type longer = "gt"
type shorter = CompareLengths<'99', '100'>;
type shorter = "lt"
type same = CompareLengths<'42', '17'>;
type same = "eq"
Try

Only when the lengths are equal do we need to look at actual digit values, scanning left to right and returning the first non-'eq' column:


type CompareSameLength<A extends string, B extends string> =
  [A, B] extends [`${infer AH extends Digit}${infer ARest}`, `${infer BH extends Digit}${infer BRest}`]
    ? CompareDigits<AH, BH> extends 'eq'
      ? CompareSameLength<ARest, BRest>
      : CompareDigits<AH, BH>
    : 'eq';

type CompareStr<A extends string, B extends string> =
  CompareLengths<A, B> extends 'eq'
    ? CompareSameLength<A, B>
    : CompareLengths<A, B>;

type GreaterThan<A extends number, B extends number> =
  CompareStr<`${A}`, `${B}`> extends 'gt' ? true : false;

type t1 = GreaterThan<24000, 11000>;
type t1 = true
type t2 = GreaterThan<9, 10>;
type t2 = false
Try

The final test distinguishes numeric comparison from lexicographic string comparison. Lexicographically, '9' > '10', but the length check correctly returns 'lt'.

Solving part 1

Now we can return to the puzzle. SumGroup processes an inventory one line at a time, adding each Calorie count to a running total with AddStr. It keeps the total as a string and calls ParseInt only once, at the end.


type SumGroup<Lines extends string[], Acc = '0'> =
  Lines extends [infer Head extends string, ...infer Rest extends string[]]
    ? SumGroup<Rest, AddStr<Acc, Head>>
    : Acc;

type total = SumGroup<['1000', '2000', '3000']>;
type total = "6000"
Try

Notice that Acc is also unconstrained. If Acc were constrained to string, TypeScript would try to evaluate AddStr<Acc, Head> before passing the result to the next recursive call, causing the same union explosion we saw earlier.

We can map SumGroup over every parsed group to get each Elf’s total:


type SumGroups<Groups extends string[][]> =
  { [K in keyof Groups]: SumGroup<Groups[K]> };

type totals = SumGroups<ParseInput<ExampleInput>>;
type totals = ["6000", "4000", "11000", "24000", "10000"]
Try

The five totals are 6000, 4000, 11000, 24000, and 10000. MaxOf processes them one at a time and uses the comparator to retain the greatest value:


type MaxStr<A extends string, B extends string> =
  CompareStr<A, B> extends 'lt' ? B : A;

type MaxOf<Values extends string[], Acc extends string = '0'> =
  Values extends [infer Head extends string, ...infer Rest extends string[]]
    ? MaxOf<Rest, MaxStr<Acc, Head>>
    : Acc;

type Part1<Input extends string> =
  ParseInt<MaxOf<SumGroups<ParseInput<Input>>>>;

type answer = Part1<ExampleInput>;
type answer = 24000
Try

The compiler produces 24000.

Solving part 2

Part 2 asks for the combined total of the top three Elves. For the example, that’s 24000 + 11000 + 10000 = 45000.

We don’t need a type-level sort. Instead, we can find the maximum three times and remove each result from the list. RemoveFirst processes the list with an accumulator until it finds the target, then appends the remaining items:


type RemoveFirst<Values extends string[], Item extends string, Acc extends string[] = []> =
  Values extends [infer Head extends string, ...infer Rest extends string[]]
    ? Head extends Item
      ? [...Acc, ...Rest]
      : RemoveFirst<Rest, Item, [...Acc, Head]>
    : Acc;

type TopThreeSum<Totals extends string[]> =
  MaxOf<Totals> extends infer First extends string
    ? RemoveFirst<Totals, First> extends infer Remaining extends string[]
      ? MaxOf<Remaining> extends infer Second extends string
        ? MaxOf<RemoveFirst<Remaining, Second>> extends infer Third extends string
          ? AddStr<AddStr<First, Second>, Third>
          : never
        : never
      : never
    : never;

type Part2<Input extends string> =
  ParseInt<TopThreeSum<SumGroups<ParseInput<Input>>>>;

type answer = Part2<ExampleInput>;
type answer = 45000
Try

TopThreeSum passes the result of the inner AddStr to the outer AddStr. Because we left AddStr’s parameters unconstrained, TypeScript can defer both additions until the three maximum values are known.

The chain of extends infer X extends string clauses in TopThreeSum repeats the local-variable technique from ParseInput: bind the first maximum, remove it, bind the second, and continue. The result is 45000.

Running the full puzzle input

The example works, but the real puzzle input contains about 250 Elves and more than 2,200 lines. After I pasted it into PuzzleInput, tsc produced both answers in about two seconds:

  • Split performs about 2,200 iterations across two levels: about 250 groups, then several lines per group. No individual tail-recursive loop approaches the 1,000-iteration limit. The original non-tail-recursive Split reports TS2589 on the same input.
  • Each addition is bounded by digit count. The totals contain at most six digits, so SumGroup does no more than a few dozen column additions per Elf.

If you want to try it on your own puzzle input, open the full code at the end of this post in the TypeScript playground, paste your input into PuzzleInput, and hover over the answers.

This isn’t a general-purpose math library. It supports addition of non-negative integers, but not subtraction, multiplication, division, decimals, or negative numbers. That is enough for Day 1.

My favorite part is that the solution uses the same column addition taught in elementary school, including carried digits. TypeScript doesn’t provide arithmetic operators for types, but tuples, string matching, and lookup tables are enough for this puzzle.

Full code

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

/** Digit character -> a tuple of that length, for counting. */
type Units = {
  '0': []; '1': [1]; '2': [1, 1]; '3': [1, 1, 1]; '4': [1, 1, 1, 1];
  '5': [1, 1, 1, 1, 1]; '6': [1, 1, 1, 1, 1, 1]; '7': [1, 1, 1, 1, 1, 1, 1];
  '8': [1, 1, 1, 1, 1, 1, 1, 1]; '9': [1, 1, 1, 1, 1, 1, 1, 1, 1];
};
type CarryUnits = { '0': []; '1': [1] };

/** Every possible column total (0..19) -> [digit out, carry out]. */
type CarryTable = [
  ['0', '0'], ['1', '0'], ['2', '0'], ['3', '0'], ['4', '0'],
  ['5', '0'], ['6', '0'], ['7', '0'], ['8', '0'], ['9', '0'],
  ['0', '1'], ['1', '1'], ['2', '1'], ['3', '1'], ['4', '1'],
  ['5', '1'], ['6', '1'], ['7', '1'], ['8', '1'], ['9', '1'],
];

/** One column: A + B + carry-in -> [digit out, carry out]. */
type AddDigits<A extends Digit, B extends Digit, C extends Carry> =
  CarryTable[[...Units[A], ...Units[B], ...CarryUnits[C]]['length'] & number];

/** '789' -> ['9', '8', '7'], so index 0 is the ones column. */
type ToReversedDigits<S, Acc extends Digit[] = []> =
  S extends `${infer Head extends Digit}${infer Rest}`
    ? ToReversedDigits<Rest, [Head, ...Acc]>
    : Acc;

/** Head and tail of a digit list, treating a missing column as '0'. */
type HeadDigit<Digits extends Digit[]> =
  Digits extends [infer Head extends Digit, ...Digit[]] ? Head : '0';

type TailDigits<Digits extends Digit[]> =
  Digits extends [Digit, ...infer Tail extends Digit[]] ? Tail : [];

/** Walk both digit lists in lockstep, padding the shorter one with '0' columns. */
type AddDigitLists<A extends Digit[], B extends Digit[], C extends Carry = '0', Acc extends string = ''> =
  [A, B] extends [[], []]
    ? (C extends '1' ? `1${Acc}` : Acc)
    : AddDigits<HeadDigit<A>, HeadDigit<B>, C> extends [infer D extends Digit, infer C2 extends Carry]
      ? AddDigitLists<TailDigits<A>, TailDigits<B>, C2, `${D}${Acc}`>
      : never;

/** Arbitrary-precision addition on decimal string literals. */
type AddStr<A, B> =
  AddDigitLists<ToReversedDigits<A>, ToReversedDigits<B>>;

type ParseInt<S> =
  S extends `${infer N extends number}` ? N : never;

type CompareDigits<A extends Digit, B extends Digit> =
  A extends B
    ? 'eq'
    : Units[A] extends [...Units[B], ...1[]]
      ? 'gt'
      : 'lt';

type CompareLengths<A extends string, B extends string> =
  A extends `${string}${infer ARest}`
    ? B extends `${string}${infer BRest}`
      ? CompareLengths<ARest, BRest>
      : 'gt'
    : B extends `${string}${infer BRest}`
      ? 'lt'
      : 'eq';

type CompareSameLength<A extends string, B extends string> =
  [A, B] extends [`${infer AH extends Digit}${infer ARest}`, `${infer BH extends Digit}${infer BRest}`]
    ? CompareDigits<AH, BH> extends 'eq'
      ? CompareSameLength<ARest, BRest>
      : CompareDigits<AH, BH>
    : 'eq';

/** Three-way comparison of non-negative decimal string literals. */
type CompareStr<A extends string, B extends string> =
  CompareLengths<A, B> extends 'eq'
    ? CompareSameLength<A, B>
    : CompareLengths<A, B>;

type Split<S extends string, Separator extends string, Acc extends string[] = []> =
  S extends `${infer Head}${Separator}${infer Rest}`
    ? Split<Rest, Separator, [...Acc, Head]>
    : [...Acc, S];

type ParseInput<S extends string> =
  Split<S, '\n\n'> extends infer Groups extends string[]
    ? { [K in keyof Groups]: Split<Groups[K], '\n'> }
    : never;

type SumGroup<Lines extends string[], Acc = '0'> =
  Lines extends [infer Head extends string, ...infer Rest extends string[]]
    ? SumGroup<Rest, AddStr<Acc, Head>>
    : Acc;

type SumGroups<Groups extends string[][]> =
  { [K in keyof Groups]: SumGroup<Groups[K]> };

type MaxStr<A extends string, B extends string> =
  CompareStr<A, B> extends 'lt' ? B : A;

type MaxOf<Values extends string[], Acc extends string = '0'> =
  Values extends [infer Head extends string, ...infer Rest extends string[]]
    ? MaxOf<Rest, MaxStr<Acc, Head>>
    : Acc;

type RemoveFirst<Values extends string[], Item extends string, Acc extends string[] = []> =
  Values extends [infer Head extends string, ...infer Rest extends string[]]
    ? Head extends Item
      ? [...Acc, ...Rest]
      : RemoveFirst<Rest, Item, [...Acc, Head]>
    : Acc;

type TopThreeSum<Totals extends string[]> =
  MaxOf<Totals> extends infer First extends string
    ? RemoveFirst<Totals, First> extends infer Remaining extends string[]
      ? MaxOf<Remaining> extends infer Second extends string
        ? MaxOf<RemoveFirst<Remaining, Second>> extends infer Third extends string
          ? AddStr<AddStr<First, Second>, Third>
          : never
        : never
      : never
    : never;

type Part1<Input extends string> =
  ParseInt<MaxOf<SumGroups<ParseInput<Input>>>>;

type Part2<Input extends string> =
  ParseInt<TopThreeSum<SumGroups<ParseInput<Input>>>>;

// ----
// Paste your puzzle input here:

type PuzzleInput = `1000
2000
3000

4000

5000
6000

7000
8000
9000

10000`;

type part1 = Part1<PuzzleInput>;
type part1 = 24000
type part2 = Part2<PuzzleInput>;
type part2 = 45000
Try