Solving Advent of Code with only TypeScript types

by Brian Simon ()

Every December, Advent of Code publishes 25 days of programming puzzles, and people solve them in Python, Rust, SQL, Excel, Factorio, you name it. In this post, we’re solving 2022’s Day 1 in TypeScript. Not with TypeScript, though. With TypeScript types. We won’t write a single line of runtime code. The puzzle input goes into a type alias, the compiler does all of the work, and the answer appears in a hover tooltip.

Dissecting the problem

The premise of the puzzle: a group of Elves is on an expedition, and each Elf is carrying some food. The puzzle input lists the Calorie count of each food item, one number per line. A blank line marks the boundary between one Elf’s inventory and the next. Here’s the example input from the puzzle:

1000
2000
3000

4000

5000
6000

7000
8000
9000

10000

This describes five Elves: the first carries items totaling 6000 Calories, the second 4000, the third 11000, the fourth 24000, and the fifth 10000. Part 1 asks: how many total Calories is the Elf with the most Calories carrying? For the 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)
    )
);

Our job is to do each of those steps with types:

  1. Split the input into groups, and the groups into lines. Template literal types are made for this.
  2. Sum each group. Types have no + operator, so we’ll be building addition ourselves.
  3. Take the max of the totals. There’s no > either.

Steps 2 and 3 are where this gets interesting.

Parsing the input

We need a type-level version of String.prototype.split(). Using the infer keyword inside a template literal type, we can match the first occurrence of a separator and capture what’s on either side of it. The natural way to write it looks like this:

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 peels one segment off the front, and the tuple spread stitches it onto whatever the recursive call returns for the rest of the string.

Tail recursion or bust

The preceding version has a problem that won’t show up until we feed it real data. Each recursive call happens inside a tuple spread, so TypeScript can’t finish evaluating the outer call until the inner call resolves. The evaluation stack grows with every segment, and TypeScript caps that stack at around 50 levels before bailing out with "Type instantiation is excessively deep and possibly infinite". My puzzle input is over 2,000 lines long. We’d blow the limit just a handful of Elves in.

Since TypeScript 4.5, the compiler performs tail-recursion elimination on conditional types. When the recursive call is the entire result of a conditional branch, TypeScript evaluates it in a loop instead of on the stack, and the limit jumps from ~50 nested instantiations to 1,000 iterations. To get that behavior, we move 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

Same result, but now the recursive call is in tail position, and a couple thousand lines of input won’t faze the compiler. Every recursive type in this post follows this accumulator pattern. Any recursive generic you expect to scale should, too.

With Split in hand, parsing the whole input is a double split: first on blank lines (\n\n) to get one string per Elf, then on single 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

Two things worth calling out here. The extends infer Groups extends string[] clause is the type-level equivalent of assigning to a local variable: it binds the result of the outer Split to Groups, and the second extends string[] (an inline infer constraint) tells TypeScript what we already know about its shape so we can index into it. And when a mapped type is applied to a tuple, the result is also a tuple, so mapping Split<..., '\n'> over the groups gives us a tuple of tuples.

The input is parsed. Now for the hard part.

Type-level addition

The most difficult thing about solving Advent of Code in the type system is that TypeScript types have no arithmetic operators. If you’ve ever done Advent of Code before, you’ll know that arithmetic is at the heart of all of its challenges. So a lack of arithmetic operations makes this much more challenging.

A + B is a syntax error in a type position. The type system does, however, have exactly one built-in counting mechanism: the length property of a tuple type is a number literal type, not just number.

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

All type-level arithmetic gets bootstrapped off that one property. If we can construct a tuple with N elements, we can produce the literal type N, and we can combine two tuples with a spread. So: build a tuple of length A, build a tuple of length B, concatenate them, read off the 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 (note the accumulator), and it works great. For small numbers.

Hitting the wall

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.

Elementary school math to the rescue

You already know an algorithm like that. It’s how you learned to add on paper: line the numbers up, add them column by column from right to left, carry the one.

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

Column addition has exactly the property we need: no matter how big the numbers get, each column only ever adds two digits and a carry. The largest possible column total is 9 + 9 + 1 = 19. Unary counting was a dead end for 7000, but it’s perfectly fine for 19.

We’ll represent numbers as strings of digit characters (we conveniently already have them as strings, fresh from Split), and work one digit at a time.

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 is a small but necessary appeasement. While A, B, and C are still unresolved type parameters, TypeScript can’t prove the computed length is one of the literal indices 0 through 19, and refuses to use it to index the tuple. Intersecting with number produces an index type TypeScript accepts. Once real digits are plugged in, the length collapses to a specific literal and the lookup returns a single entry.

Walking the columns

Column addition runs right to left, but template literal matching peels characters off the left. The easy fix is to reverse the digits up front, so that 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;

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

Prepending each plucked character onto the accumulator reverses the string for free, and the infer Head extends Digit constraint narrows each character to our Digit union as it’s matched.

Now the main loop. Walk both digit lists in lockstep, add each column pair with AddDigits, prepend the resulting digit onto a string accumulator (prepending un-reverses the result), and thread the carry through to the next column. When one number is shorter than the other, treat its missing columns as '0'. When both lists are exhausted, emit one final '1' if there’s still a carry. Two little helpers, HeadDigit and TailDigits, handle the padding by defaulting to '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

Arbitrary-precision addition. The recursion depth is the digit count, so adding five-digit Calorie counts takes five iterations instead of tens of thousands. We’re done with addition, right? All that’s left is a polite wrapper that accepts number literals, converts them to strings with a template literal, 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

A wild union explosion appears

Wrong! We didn’t even instantiate Add with anything yet. The declaration alone blows up.

Here’s what’s happening. To verify that AddStr<...> is a legal argument for ParseInt’s S extends string constraint, TypeScript evaluates our addition machinery while A and B are still unknown, substituting each type parameter’s constraint in place of an actual value. And under those conditions, HeadDigit can’t match anything specific, so it falls back to the full Digit union: all ten digits at once. AddDigits of two ten-digit unions is a bigger union. Then the loop in AddDigitLists keeps iterating, and every pass multiplies the accumulated template literal union by another factor of ten. A few columns in, the union crosses TypeScript’s hard cap of 100,000 members and the compiler gives up.

The fix is to stop asking. TypeScript only runs the addition because ParseInt constrains its parameter to S extends string, and checking that constraint means proving the result is a string, which means evaluating the loop. So drop the constraint. With nothing to verify up front, ParseInt takes whatever it’s given, the loop stays frozen until real digits arrive, and the declaration goes through:


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

One word lighter, and Add checks without complaint. But ParseInt isn’t the only thing that asks. Anywhere an unevaluated AddStr<...> lands in a slot marked extends string, TypeScript tries to verify it and the union detonates again. Two more such slots are coming up: SumGroup threads its running total of type AddStr<Acc, Head> back into its own Acc parameter, and TopThreeSum nests AddStr<AddStr<...>, ...>. That’s why AddStr and ToReversedDigits already take bare type parameters, and why SumGroup will too. The extends string bounds have to come off every link in the chain.

The cost is real: these building blocks no longer announce that they want strings, so feeding AddStr a boolean gets you a baffling never downstream instead of an error at the call site. For a solver that only ever hands them digit strings, that’s a fair trade, and the recursive call is still in tail position, so tail-recursion elimination comes along for free.

Addition: done. Add<7000, 8000> evaluates faster than you can hover over it.

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.

Comparing single digits first. We can reuse the Units table: if laying out B’s tuple leaves room to spare inside A’s tuple, then A is bigger.


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[]] asks: does A’s tuple start with all of B’s tuple? That’s true whenever A >= B, and since the first branch already handled equality, reaching it means A > B.

For multi-digit numbers, there’s a shortcut worth stealing before we do any digit comparisons at all: a number with more digits is always bigger. Our numbers are non-negative integers with no leading zeros, so 100 beats 99 purely on length. We can compare lengths by stripping one character from each string per iteration; whoever runs out 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

That last test is the one that trips up naive string comparison: '9' > '10' lexicographically, but the length check fires first and gets it right.

Solving part 1

Time to circle back and plug everything in. Summing one Elf’s inventory is a fold over the lines with AddStr. Note that we stay in string land the whole way through; converting back and forth to number between every addition would just be busywork. ParseInt happens exactly once, at the very 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

Mapping that over every parsed group gives us 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

There are our five Elves: 6000, 4000, 11000, 24000, and 10000. All that’s left is folding over the totals with the comparator to keep the biggest one:


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

24000. The compiler agrees with the puzzle. ⭐

Part 2: the top three

Part 2 of the puzzle asks for the combined total of the top three Elves (in case the strongest Elf runs out of snacks, naturally). For the example input that’s 24000 + 11000 + 10000 = 45000.

We don’t need a type-level sort for this. Finding the top three is just finding the max three times, removing it from the list after each round. RemoveFirst walks the list with an accumulator until it hits the item, then splices the remainder on:


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

The chain of extends infer X extends string clauses in TopThreeSum is the same local-variable trick from ParseInput, just stacked: bind the first max, bind the list without it, bind the second max, and so on. 45000. Second star. ⭐⭐

Running it on the real input

The example input is cute, but the real puzzle input is around 250 Elves and over 2,200 lines. Does this hold up?

It does. I pasted my full puzzle input into the Input type parameter, and tsc produced both answers in about two seconds, all within the compiler’s limits:

  • Split on the full input is roughly 2,200 iterations split across two levels (~250 groups, then a handful of lines per group), so no single tail-recursive loop comes near the 1,000-iteration ceiling. With the original non-tail-recursive Split from the top of this post, the same input dies instantly with TS2589.
  • Every addition is bounded by digit count. The totals max out at six digits, so SumGroup does at most 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.

None of this is a general-purpose math library, to be clear. There’s only addition: no subtraction, multiplication, or division. It assumes non-negative integers, so decimals and negative numbers are off the table, and a stray minus sign or decimal point would just confuse the digit parser. But Day 1 only ever asks us to add up non-negative Calorie counts, so none of that matters. The implementation is exactly as capable as the puzzle needs it to be, and not one bit more.

My favorite part of all this is that the winning strategy turned out to be second-grade paper arithmetic, carried digits and all. The type system never learned math. It learned tuples, string matching, and lookup tables, and apparently that’s enough.

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