In TS 4.5 tail call optimization was added for recursive generics. The following snippet computes Fibonacci numbers (in unary) up to F12, but for F13 it fails with the usual "Type instantiation is excessively deep and possibly infinite" exception. This implementation of the Fibonacci function was chosen because it uses two calls of itself in non-tail-call positions, and is important for demonstration purposes.
The only recursive function here is Run
, the rest of functions (and interface
-based function references) should not significantly modify current stack depth. Why TCO didn't work, and how to make it work again?
type Done<A> = { type: 'done', value: A };
type More<F, X> = { type: 'more', value: X, fn: F };
type FlatMap1<X, F> = { type: 'flatMap', value: X, fn: F }
interface FlatMap2<G, F> { a: unknown; r: FlatMap<Call<G, this['a']>, F> }
type FlatMap<X, F> = X extends FlatMap1<infer X, infer G> ? FlatMap1<X, FlatMap2<G, F>> : FlatMap1<X, F>
type Run<T> =
T extends Done<infer V> ? V :
Run<
T extends More<infer F, infer X> ? Call<F, X> :
T extends FlatMap1<infer X, infer F> ?
X extends Done<infer V> ? Call<F, V> :
X extends More<infer G, infer V> ? FlatMap<Call<G, V>, F> :
X extends FlatMap1<infer V, infer G> ? FlatMap<V, FlatMap2<G, F>> :
never :
never
>
interface Fib2<X> { a: unknown; r: Done<Add<X, this['a']>> }
interface Fib1<N> { a: unknown; r: FlatMap<More<FibR, Sub<N, '11'>>, Fib2<this['a']>> }
interface FibR { a: unknown; r: Fib<this['a']> }
type Fib<N> =
N extends ('' | '1') ? Done<N> :
FlatMap<
More<FibR, Sub<N, '1'>>,
Fib1<N>
>
type R1 = Run<Fib<'1111111111111'>>
// utils
interface Fn { a: unknown; r: unknown }
type Call<F, X> = F extends Fn ? (F & {a: X})['r'] : never;
type Add<A, B> = A extends string ? B extends string ? `${A}${B}` : never : never
type Sub<A, B> = B extends string ? A extends `${B}${infer D}` ? D : never : never
What happens when TCO works?
Equivalent JavaScript (and intentionally ugly to prove it) code computes much bigger Fibonacci numbers (bigger than F35), barring conversion of tail recursion to explicit loop, and using binary numbers instead of unary. The only limit here is size of the heap, because the whole computation was trampolined (read here about this exact approach, and here is more reader-friendly explanation of the concept).
const done = a => ({type: 'done', value: a});
const more = (f, x) => ({type: 'more', value: x, fn: f});
const flatMap1 = (x, f) => ({type: 'flatMap', value: x, fn: f});
const flatMap2 = (g, f) => y => flatMap(g(y), f);
const flatMap = (x, f) => x.type === 'flatMap' ? flatMap1(x.value, flatMap2(x.fn, f)) : flatMap1(x, f);;
const run = tt => {
for (let t = tt;;) {
if (t.type === 'done') { return t.value; } else
t = (() => {
if (t.type === 'more') { return t.fn(t.value); } else
if (t.type === 'flatMap') { const x = t.value, f = t.fn;
if (x.type === 'done') return f(x.value);
else if (x.type === 'more') return flatMap(x.fn(x.value), f);
else if (x.type === 'flatMap') return flatMap(x.value, flatMap2(x.fn, f));
else throw new Error();
}
else throw new Error();
})();
}
};
const fib2 = x => y => done(x + y)
const fib1 = n => x => flatMap(more(fib, n - 2), fib2(x));
const fib = n => n < 2
? done(n)
: flatMap(
more(fib, n - 1),
fib1(n),
);
console.log(run(fib(30)));
So it seems I should have been reading better, because in PR Anders clearly states that it's still limited:
This would mean we're out of fortune, and Turing-complete computations of arbitrary complexity are impossible, if I didn't learn a trick back in C++ days.
Let's state the problem: we're iterating a certain function numerous times until it "completes". Let's use some simpler function: the one that concatenates a 1 to a string.
We can iterate it twice in this way:
Each next iteration like this will multiply number of iterations by two:
We abstract over the number of iterations of doubling by adding an extra argument: (unary) number of doublings.
This allows us 2999 iterations of
Test0
withTestN<'', 'one 999 times'>
. After that we'll hit instantiation limit. Of course, we don't usually use all the 2N iterations for every function, so in order to exit early we return some "done" value.Let's talk about performance. If we called
TestN
fromTestN
K times instead of 2, we'd get at most K999 iterations ofTest0
, but even forK = 2
it will be able to iterate until the heat death of the universe.When the function will be exiting, it will go through "passthrough" route at most once per level (
O(K)
), multiplied by at most the iteration depthO(L)
. In order to reduce extraneous computations, branching level should be the smallest (choice of 2 was good), as well as depth aka doubling count.With this approach I was immediately able to compute F20, and it's not even remotely optimized. There is another undocumented limit though (TS code,
instantiateTypeWithAlias
) that enforces obfuscated code practices.