trampoline-ts
Version:
A type-safe way to emulate tail-call optimization with trampolines
117 lines (93 loc) • 4.05 kB
text/typescript
import { assert as typeAssert, IsExact, Has } from 'conditional-type-checks';
import { trampoline, isThunk, ThunkOrValue, trampolineAsync } from '../src';
import { ArgumentTypes } from '../src/types';
describe('trampoline', () => {
describe('trampoline(fn)', () => {
it('returns a tail call recursive function', () => {
expect(trampoline(() => true)).toBeInstanceOf(Function);
});
it('preserves argument types in the returned function', () => {
const impl = (_1: number, _2: string, _3: boolean) => true;
const fn = trampoline(impl);
// tslint:disable-next-line:ban-types
typeAssert<Has<typeof fn, Function>>(true);
typeAssert<IsExact<ArgumentTypes<typeof fn>, [number, string, boolean]>>(true);
});
it('preserves return type in the returned function', () => {
const impl = () => true;
const fn = trampoline(impl);
// tslint:disable-next-line:ban-types
typeAssert<Has<typeof fn, Function>>(true);
typeAssert<IsExact<ReturnType<typeof fn>, boolean>>(true);
});
it('removes "ThunkOrValue" from the returned functions return type', () => {
const impl = (): ThunkOrValue<boolean> => true;
const fn = trampoline(impl);
// tslint:disable-next-line:ban-types
typeAssert<Has<typeof fn, Function>>(true);
typeAssert<IsExact<ReturnType<typeof fn>, boolean>>(true);
});
it('preserves argument types in "cont"', () => {
const impl = (_1: number, _2: string, _3: boolean) => true;
const { cont } = trampoline(impl);
// tslint:disable-next-line:ban-types
typeAssert<Has<typeof cont, Function>>(true);
typeAssert<IsExact<ArgumentTypes<typeof cont>, [number, string, boolean]>>(true);
});
it('preserves return type in "cont" returned thunk', () => {
const impl = () => true;
const { cont } = trampoline(impl);
const thunk = cont();
// tslint:disable-next-line:ban-types
typeAssert<Has<typeof cont, Function>>(true);
typeAssert<Has<ReturnType<typeof thunk>, boolean>>(true);
});
it('returns a function with a thunk returning "cont" method', () => {
const fn = jest.fn((input: string) => `${input}!`);
const { cont } = trampoline(fn);
const thunk = cont('input');
expect(isThunk(thunk)).toBe(true);
expect(fn).not.toHaveBeenCalled();
expect(thunk()).toBe('input!');
expect(fn).toHaveBeenNthCalledWith(1, 'input');
});
it(`loops until the passed function doesn't return a thunk`, () => {
const timesToLoop = 5;
const fn = trampoline((times: number = 0): ThunkOrValue<number> => {
return times < timesToLoop ? fn.cont(times + 1) : times;
});
const contSpy = jest.spyOn(fn, 'cont');
fn();
expect(contSpy).toHaveBeenCalledTimes(5);
});
it(`doesn't throw a stack overflow error`, () => {
const brokenFactorial = (n: number, acc: number = 1): number => {
return n ? brokenFactorial(n - 1, acc * n) : acc;
};
const factorial = trampoline((n: number, acc: number = 1): ThunkOrValue<number> => {
return n
? factorial.cont(n - 1, acc * n)
: acc;
});
expect(() => brokenFactorial(32768)).toThrowError('Maximum call stack size exceeded');
expect(factorial(32768)).toEqual(Infinity);
});
it('supports returning functions', () => {
const fn = trampoline((): ThunkOrValue<() => string> => {
return () => 'hello';
});
expect(fn()).toBeInstanceOf(Function);
expect(fn()()).toBe('hello');
});
it('supports async functions', async () => {
const sleep = async (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
const factorial = trampolineAsync(async (n: number, acc: number = 1): Promise<ThunkOrValue<number>> => {
await sleep(10);
return n
? factorial.cont(n - 1, acc * n)
: acc;
});
expect(await factorial(2)).toBe(2);
});
});
});