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
10000This 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:
- Split the input into groups, then split each group into lines.
- Sum the values in each group.
- 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'>;TryEach 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'>;TryThe 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 >;TryThe 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'];TryIf 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 NaiveAdd <A extends number, B extends number> =
[...UnaryTuple <A >, ...UnaryTuple <B >]['length'];
type five = NaiveAdd <2, 3>;TryThis 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.2589Type instantiation is excessively deep and possibly infinite.TryNo 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 3Column 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 sevenPlusEightPlusCarry = AddDigits <'7', '8', '1'>;TryUnits 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'>;TryPrepending 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 carrying = AddStr <'999', '1'>;
type uneven = AddStr <'5', '99999'>;TryThe 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.2590Expression produces a union type that is too complex to represent.TryAvoiding 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 alsoWorks = Add <999, 1>;TryFor 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 lt = CompareDigits <'2', '6'>;
type eq = CompareDigits <'4', '4'>;TryUnits[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 shorter = CompareLengths <'99', '100'>;
type same = CompareLengths <'42', '17'>;TryOnly 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 t2 = GreaterThan <9, 10>;TryThe 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']>;TryNotice 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 >>;TryThe 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 >;TryThe 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 >;TryTopThreeSum 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:
Splitperforms 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-recursiveSplitreportsTS2589on the same input.- Each addition is bounded by digit count. The totals contain at most six digits, so
SumGroupdoes 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 part2 = Part2 <PuzzleInput >;Try