├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── pnpm-lock.yaml ├── scripts └── test-ci.sh ├── src ├── index.ts ├── thunk.ts ├── trampoline.ts └── types.ts ├── test ├── thunk.spec.ts └── trampoline.spec.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | .eslintrc.js 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:@typescript-eslint/recommended', 8 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 9 | 'prettier', 10 | ], 11 | parser: '@typescript-eslint/parser', 12 | parserOptions: { 13 | project: 'tsconfig.json', 14 | sourceType: 'module', 15 | }, 16 | plugins: [ 17 | 'eslint-plugin-import', 18 | 'eslint-plugin-jsdoc', 19 | 'eslint-plugin-prefer-arrow', 20 | '@typescript-eslint', 21 | ], 22 | root: true, 23 | rules: { 24 | '@typescript-eslint/adjacent-overload-signatures': 'error', 25 | '@typescript-eslint/array-type': [ 26 | 'error', 27 | { 28 | default: 'array', 29 | }, 30 | ], 31 | '@typescript-eslint/ban-types': [ 32 | 'error', 33 | { 34 | types: { 35 | Object: { 36 | message: 'Avoid using the `Object` type. Did you mean `object`?', 37 | }, 38 | Function: { 39 | message: 40 | 'Avoid using the `Function` type. Prefer a specific function type, like `() => void`.', 41 | }, 42 | Boolean: { 43 | message: 'Avoid using the `Boolean` type. Did you mean `boolean`?', 44 | }, 45 | Number: { 46 | message: 'Avoid using the `Number` type. Did you mean `number`?', 47 | }, 48 | String: { 49 | message: 'Avoid using the `String` type. Did you mean `string`?', 50 | }, 51 | Symbol: { 52 | message: 'Avoid using the `Symbol` type. Did you mean `symbol`?', 53 | }, 54 | }, 55 | }, 56 | ], 57 | '@typescript-eslint/consistent-type-assertions': 'error', 58 | '@typescript-eslint/dot-notation': 'error', 59 | '@typescript-eslint/explicit-function-return-type': 'off', 60 | '@typescript-eslint/explicit-module-boundary-types': 'off', 61 | '@typescript-eslint/naming-convention': [ 62 | 'off', 63 | { 64 | selector: 'variable', 65 | format: ['camelCase', 'UPPER_CASE', 'PascalCase'], 66 | leadingUnderscore: 'allow', 67 | trailingUnderscore: 'forbid', 68 | }, 69 | ], 70 | '@typescript-eslint/no-empty-function': 'error', 71 | '@typescript-eslint/no-empty-interface': 'error', 72 | '@typescript-eslint/no-explicit-any': 'off', 73 | '@typescript-eslint/no-misused-new': 'error', 74 | '@typescript-eslint/no-namespace': 'error', 75 | '@typescript-eslint/no-parameter-properties': 'off', 76 | '@typescript-eslint/no-shadow': [ 77 | 'error', 78 | { 79 | hoist: 'all', 80 | }, 81 | ], 82 | '@typescript-eslint/no-unused-expressions': 'error', 83 | 'no-unused-vars': 'off', 84 | '@typescript-eslint/no-unused-vars': [ 85 | 'error', // or "error" 86 | { 87 | argsIgnorePattern: '^_', 88 | varsIgnorePattern: '^_', 89 | caughtErrorsIgnorePattern: '^_', 90 | }, 91 | ], 92 | '@typescript-eslint/no-use-before-define': 'off', 93 | '@typescript-eslint/no-var-requires': 'error', 94 | '@typescript-eslint/prefer-for-of': 'error', 95 | '@typescript-eslint/prefer-function-type': 'error', 96 | '@typescript-eslint/prefer-namespace-keyword': 'error', 97 | '@typescript-eslint/quotes': 'off', 98 | '@typescript-eslint/triple-slash-reference': [ 99 | 'error', 100 | { 101 | path: 'always', 102 | types: 'prefer-import', 103 | lib: 'always', 104 | }, 105 | ], 106 | '@typescript-eslint/typedef': 'off', 107 | '@typescript-eslint/unified-signatures': 'error', 108 | 'comma-dangle': ['error', 'always-multiline'], 109 | complexity: 'off', 110 | 'constructor-super': 'error', 111 | 'dot-notation': 'off', 112 | eqeqeq: ['error', 'smart'], 113 | 'guard-for-in': 'error', 114 | 'id-denylist': [ 115 | 'error', 116 | 'any', 117 | 'Number', 118 | 'number', 119 | 'String', 120 | 'string', 121 | 'Boolean', 122 | 'boolean', 123 | 'Undefined', 124 | 'undefined', 125 | ], 126 | 'id-match': 'error', 127 | 'import/no-default-export': 'error', 128 | 'import/order': [ 129 | 'off', 130 | { 131 | alphabetize: { 132 | caseInsensitive: true, 133 | order: 'asc', 134 | }, 135 | 'newlines-between': 'ignore', 136 | groups: [ 137 | ['builtin', 'external', 'internal', 'unknown', 'object', 'type'], 138 | 'parent', 139 | ['sibling', 'index'], 140 | ], 141 | distinctGroup: false, 142 | pathGroupsExcludedImportTypes: [], 143 | pathGroups: [ 144 | { 145 | pattern: './', 146 | patternOptions: { 147 | nocomment: true, 148 | dot: true, 149 | }, 150 | group: 'sibling', 151 | position: 'before', 152 | }, 153 | { 154 | pattern: '.', 155 | patternOptions: { 156 | nocomment: true, 157 | dot: true, 158 | }, 159 | group: 'sibling', 160 | position: 'before', 161 | }, 162 | { 163 | pattern: '..', 164 | patternOptions: { 165 | nocomment: true, 166 | dot: true, 167 | }, 168 | group: 'parent', 169 | position: 'before', 170 | }, 171 | { 172 | pattern: '../', 173 | patternOptions: { 174 | nocomment: true, 175 | dot: true, 176 | }, 177 | group: 'parent', 178 | position: 'before', 179 | }, 180 | ], 181 | }, 182 | ], 183 | 'jsdoc/check-alignment': 'error', 184 | 'jsdoc/check-indentation': 'error', 185 | 'max-classes-per-file': ['error', 1], 186 | 'new-parens': 'error', 187 | 'no-bitwise': 'error', 188 | 'no-caller': 'error', 189 | 'no-cond-assign': 'error', 190 | 'no-console': 'error', 191 | 'no-debugger': 'error', 192 | 'no-empty': 'error', 193 | 'no-empty-function': 'off', 194 | 'no-eval': 'error', 195 | 'no-fallthrough': 'off', 196 | 'no-invalid-this': 'off', 197 | 'no-new-wrappers': 'error', 198 | 'no-shadow': 'off', 199 | 'no-throw-literal': 'error', 200 | 'no-trailing-spaces': 'error', 201 | 'no-undef-init': 'error', 202 | 'no-underscore-dangle': 'off', 203 | 'no-unsafe-finally': 'error', 204 | 'no-unused-expressions': 'off', 205 | 'no-unused-labels': 'error', 206 | 'no-use-before-define': 'off', 207 | 'no-var': 'error', 208 | 'object-shorthand': 'error', 209 | 'one-var': ['error', 'never'], 210 | 'prefer-arrow/prefer-arrow-functions': [ 211 | 'error', 212 | { 213 | allowStandaloneDeclarations: true, 214 | }, 215 | ], 216 | 'prefer-const': 'error', 217 | quotes: 'off', 218 | radix: 'error', 219 | 'spaced-comment': [ 220 | 'error', 221 | 'always', 222 | { 223 | markers: ['/'], 224 | }, 225 | ], 226 | 'use-isnan': 'error', 227 | 'valid-typeof': 'off', 228 | }, 229 | }; 230 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | pull_request: 8 | types: [opened, reopened, synchronize] 9 | 10 | permissions: 11 | # for checkout 12 | contents: read 13 | 14 | jobs: 15 | build: 16 | name: Build 17 | runs-on: ubuntu-latest 18 | permissions: 19 | # to be able to publish a GitHub release 20 | contents: write 21 | # to be able to comment on released issues 22 | issues: write 23 | # to be able to comment on released pull requests 24 | pull-requests: write 25 | # to enable use of OIDC for npm provenance 26 | id-token: write 27 | 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v3 31 | with: 32 | fetch-depth: 0 33 | 34 | - name: Install pnpm 35 | uses: pnpm/action-setup@v2 36 | with: 37 | version: 8.6 38 | 39 | - name: Setup Node.js 40 | uses: actions/setup-node@v3 41 | with: 42 | node-version: 20 43 | cache: 'pnpm' 44 | 45 | - name: Install dependencies 46 | run: pnpm install --frozen-lockfile 47 | 48 | - name: Check format 49 | run: pnpm run format 50 | 51 | - name: Lint 52 | run: pnpm run lint 53 | 54 | - name: Run tests 55 | run: pnpm run test-ci 56 | 57 | - name: Report code coverage 58 | uses: ArtiomTr/jest-coverage-report-action@v2 59 | with: 60 | github-token: ${{ secrets.GITHUB_TOKEN }} 61 | skip-step: install 62 | coverage-file: ./coverage/gh-coverage-report-action.json 63 | base-coverage-file: ./coverage/gh-coverage-report-action.json 64 | 65 | - name: Publish 66 | if: ${{ github.ref == 'refs/heads/master' }} 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 70 | run: ls -la node_modules; pnpm exec semantic-release 71 | 72 | - name: Update coverage badge 73 | if: ${{ github.ref == 'refs/heads/master' }} 74 | uses: we-cli/coverage-badge-action@main 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | .vscode 5 | Session.vim 6 | *.log 7 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | pnpm exec commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | pnpm exec lint-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kschat/trampoline-ts/275f47ed8dfdd4e612faa2f9863fa1688e0bd6ec/.npmrc -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.8 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | pnpm-lock.yaml 3 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | printWidth: 100 2 | singleQuote: true 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [1.1.0](https://github.com/kschat/trampoline-ts/compare/v1.0.2...v1.1.0) (2019-04-26) 2 | 3 | ### Features 4 | 5 | - support async/Promise-returning functions ([fee31d2](https://github.com/kschat/trampoline-ts/commit/fee31d2)) 6 | 7 | ## [1.0.2](https://github.com/kschat/trampoline-ts/compare/v1.0.1...v1.0.2) (2019-04-25) 8 | 9 | ### Bug Fixes 10 | 11 | - auto publish ([f59e704](https://github.com/kschat/trampoline-ts/commit/f59e704)) 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Kyle Schattler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Trampoline TS 2 | 3 | [![Build Status](https://github.com/kschat/trampoline-ts/actions/workflows/main.yml/badge.svg)](https://github.com/kschat/trampoline-ts/actions) 4 | [![Coverage Status](https://kschat.github.io/trampoline-ts/badges/coverage.svg)](https://github.com/kschat/trampoline-ts/actions) 5 | [![npm version](https://badge.fury.io/js/trampoline-ts.svg)](https://badge.fury.io/js/trampoline-ts) 6 | 7 | A type-safe way to emulate tail-call optimization with trampolines 8 | 9 | ## Install 10 | 11 | ```sh 12 | npm i trampoline-ts 13 | # or 14 | yarn add trampoline-ts 15 | # or 16 | pnpm add trampoline-ts 17 | ``` 18 | 19 | ## TypeScript Compatibility 20 | 21 | Requires a TypeScript version >= 3.0 22 | 23 | ## Usage 24 | 25 | ```ts 26 | import { trampoline, ThunkOrValue } from 'trampoline-ts'; 27 | 28 | const factorial = trampoline((n: number, acc: number = 1): ThunkOrValue => { 29 | return n 30 | ? // Note: calling factorial.cont instead of factorial directly 31 | factorial.cont(n - 1, acc * n) 32 | : acc; 33 | }); 34 | 35 | factorial(32768); // No stack overflow 36 | ``` 37 | 38 | ## API 39 | 40 | ##### `trampoline ThunkOrValue)>(fn: F): Trampoline` 41 | 42 | Takes a Tail Recursive Form function that returns a `ThunkOrValue` and 43 | converts it to a tail-call optimized function. The returned function 44 | `Trampoline` will have the exact same type signature as the passed 45 | function except for one change, the return type will not contain 46 | `ThunkOrValue`, it will just be `T`. 47 | 48 | It's important that `fn` wraps the return type in `ThunkOrValue`. If this is 49 | omitted, TypeScript will not be able to infer the type of the returned 50 | function and will default to `any`. 51 | 52 | Also note that to continue function recursion `Trampoline.cont()` should 53 | be called, and not the function directly. `.cont()` has the same type 54 | signature as the passed function, so there's no way to call it incorrectly. 55 | 56 | ##### `trampolineAsync ThunkOrValue)>(fn: F): TrampolineAsync` 57 | 58 | Same as `trampoline`, but works with `async/Promise-returning` functions. 59 | 60 | ##### `Trampoline ThunkOrValue>` 61 | 62 | A function that represents a tail-call optimized function. 63 | 64 | ##### `TrampolineAsync ThunkOrValue>` 65 | 66 | An async function that represents a tail-call optimized function. 67 | 68 | ##### `Trampoline.cont(...args: ArgumentTypes): Thunk>` 69 | 70 | Function used to safely continue recursion. It captures `F`'s argument and 71 | return types and thus has the same type signature. 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trampoline-ts", 3 | "version": "1.1.0", 4 | "main": "dist/src/index.js", 5 | "description": "A type-safe way to emulate tail-call optimization with trampolines", 6 | "types": "dist/src/index.d.ts", 7 | "author": "Kyle Schattler", 8 | "license": "MIT", 9 | "keywords": [ 10 | "trampoline", 11 | "recursion", 12 | "tail call", 13 | "typesafe", 14 | "typescript" 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/kschat/trampoline-ts.git" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/kschat/trampoline-ts/issues" 22 | }, 23 | "homepage": "https://github.com/kschat/trampoline-ts#readme", 24 | "scripts": { 25 | "prepublishOnly": "pnpm run build", 26 | "prepare": "husky install", 27 | "build": "tsc", 28 | "format-base": "prettier --log-level warn", 29 | "format": "pnpm run format-base --check .", 30 | "format-fix": "pnpm run format-base --write .", 31 | "lint-base": "eslint --ext .ts,.js --max-warnings 0 --report-unused-disable-directives", 32 | "lint": "pnpm run lint-base .", 33 | "lint-fix": "pnpm run lint-base --fix .", 34 | "test": "jest", 35 | "test-ci": "./scripts/test-ci.sh" 36 | }, 37 | "lint-staged": { 38 | "{src,test}/**/*.ts": [ 39 | "pnpm run format-base --fix -u", 40 | "pnpm run lint-base --fix" 41 | ], 42 | "!**/*.{js,ts,jsx,tsx}": "pnpm run format-base --fix -u" 43 | }, 44 | "commitlint": { 45 | "extends": [ 46 | "@commitlint/config-conventional" 47 | ] 48 | }, 49 | "release": { 50 | "plugins": [ 51 | "@semantic-release/commit-analyzer", 52 | "@semantic-release/release-notes-generator", 53 | "@semantic-release/npm", 54 | "@semantic-release/changelog", 55 | "@semantic-release/github", 56 | "@semantic-release/git" 57 | ] 58 | }, 59 | "jest": { 60 | "collectCoverage": true, 61 | "preset": "ts-jest", 62 | "testEnvironment": "node", 63 | "testMatch": [ 64 | "/test/**/*.spec.ts" 65 | ], 66 | "verbose": true 67 | }, 68 | "devDependencies": { 69 | "@commitlint/cli": "^17.8.0", 70 | "@commitlint/config-conventional": "^17.8.0", 71 | "@semantic-release/changelog": "^6.0.3", 72 | "@semantic-release/commit-analyzer": "^11.0.0", 73 | "@semantic-release/git": "^10.0.1", 74 | "@semantic-release/github": "^9.2.1", 75 | "@semantic-release/npm": "^11.0.0", 76 | "@semantic-release/release-notes-generator": "^12.0.0", 77 | "@types/jest": "^29.5.6", 78 | "@types/node": "^20.8.7", 79 | "@typescript-eslint/eslint-plugin": "^6.8.0", 80 | "@typescript-eslint/parser": "^6.8.0", 81 | "conditional-type-checks": "^1.0.6", 82 | "eslint": "^8.51.0", 83 | "eslint-config-prettier": "^9.0.0", 84 | "eslint-plugin-import": "^2.28.1", 85 | "eslint-plugin-jsdoc": "^46.8.2", 86 | "eslint-plugin-prefer-arrow": "^1.2.3", 87 | "husky": "^8.0.3", 88 | "jest": "^29.7.0", 89 | "lint-staged": "^15.0.2", 90 | "prettier": "^3.0.3", 91 | "semantic-release": "^22.0.5", 92 | "ts-jest": "^29.1.1", 93 | "typescript": "^5.2.2" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /scripts/test-ci.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # First coverage settings are for the GH action that adds a comment with coverage results. 4 | # The second set of coverage settings are used for the coverage badge GH action. 5 | pnpm exec jest --ci \ 6 | --json --coverage --testLocationInResults --outputFile coverage/gh-coverage-report-action.json \ 7 | --coverageReporters json-summary 8 | 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './thunk'; 2 | export * from './trampoline'; 3 | -------------------------------------------------------------------------------- /src/thunk.ts: -------------------------------------------------------------------------------- 1 | export const THUNK_SYMBOL: unique symbol = Symbol('thunk'); 2 | 3 | export type Thunk = { 4 | __THUNK__: typeof THUNK_SYMBOL; 5 | (): T; 6 | }; 7 | 8 | export type ThunkOrValue = T | Thunk; 9 | 10 | export type UnwrapThunkDeep = { 11 | 0: T extends Thunk ? UnwrapThunkDeep : T; 12 | }[T extends ThunkOrValue ? 0 : never]; 13 | 14 | export const isThunk = (value: unknown): value is Thunk => { 15 | return typeof value === 'function' && '__THUNK__' in value && value.__THUNK__ === THUNK_SYMBOL; 16 | }; 17 | 18 | export const toThunk = (fn: () => R): Thunk => { 19 | const thunk = () => fn(); 20 | thunk.__THUNK__ = THUNK_SYMBOL; 21 | return thunk; 22 | }; 23 | -------------------------------------------------------------------------------- /src/trampoline.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentTypes } from './types'; 2 | import { Thunk, UnwrapThunkDeep, isThunk, toThunk, ThunkOrValue } from './thunk'; 3 | 4 | export type UnwrapPromise = T extends Promise ? Exclude> : T; 5 | 6 | export type Unbox = UnwrapThunkDeep>; 7 | 8 | export type Cont = (...args: A) => Thunk>; 9 | 10 | export interface Trampoline any> { 11 | (...args: ArgumentTypes): Unbox>; 12 | cont: Cont, ReturnType>; 13 | } 14 | 15 | export interface TrampolineAsync any> { 16 | (...args: ArgumentTypes): Promise>>; 17 | cont: Cont, ReturnType>; 18 | } 19 | 20 | export const trampoline = any>(fn: F): Trampoline => { 21 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 22 | const cont = (...args: ArgumentTypes) => toThunk(() => fn(...args)); 23 | 24 | return Object.assign( 25 | (...args: ArgumentTypes): Unbox> => { 26 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 27 | let result: ThunkOrValue> = fn(...args); 28 | 29 | while (isThunk>(result)) { 30 | result = result(); 31 | } 32 | 33 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 34 | return result; 35 | }, 36 | { cont }, 37 | ); 38 | }; 39 | 40 | export const trampolineAsync = any>(fn: F): TrampolineAsync => { 41 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 42 | const cont = (...args: ArgumentTypes) => toThunk(() => fn(...args)); 43 | 44 | return Object.assign( 45 | async (...args: ArgumentTypes): Promise>> => { 46 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 47 | let result: ThunkOrValue>> = await fn(...args); 48 | 49 | while (isThunk(result)) { 50 | result = await result(); 51 | } 52 | 53 | return result; 54 | }, 55 | { cont }, 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type ArgumentTypes any> = T extends (...args: infer A) => any 2 | ? A 3 | : never; 4 | -------------------------------------------------------------------------------- /test/thunk.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert as typeAssert, IsExact } from 'conditional-type-checks'; 2 | import { isThunk, toThunk, THUNK_SYMBOL, UnwrapThunkDeep, Thunk, ThunkOrValue } from '../src/thunk'; 3 | 4 | describe('thunk', () => { 5 | describe('isThunk(value)', () => { 6 | it('returns true if the value is a thunk created by this lib', () => { 7 | expect(isThunk(toThunk(() => null))).toBe(true); 8 | }); 9 | 10 | it('returns false if the value is not a thunk', () => { 11 | expect(isThunk(() => null)).toBe(false); 12 | expect(isThunk(true)).toBe(false); 13 | expect(isThunk(1)).toBe(false); 14 | expect(isThunk('')).toBe(false); 15 | }); 16 | }); 17 | 18 | describe('toThunk', () => { 19 | it('converts a thunk-like function to a thunk', () => { 20 | const thunk = toThunk(() => 'some value'); 21 | expect(thunk).toHaveProperty('__THUNK__', THUNK_SYMBOL); 22 | expect(thunk()).toBe('some value'); 23 | }); 24 | }); 25 | 26 | describe('UnwrapThunk', () => { 27 | it('removes recursively removes all instances of Thunk from T', () => { 28 | typeAssert, 1>>(true); 29 | typeAssert>, 1>>(true); 30 | typeAssert>, 1>>(true); 31 | typeAssert>>, 1>>(true); 32 | typeAssert | ThunkOrValue<2>>>, 1 | 2>>( 33 | true, 34 | ); 35 | typeAssert>>, 1 | 2>>(true); 36 | typeAssert | ThunkOrValue<3>>>, 1 | 2 | 3>>(true); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/trampoline.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert as typeAssert, IsExact, Has } from 'conditional-type-checks'; 2 | import { trampoline, isThunk, ThunkOrValue, trampolineAsync } from '../src'; 3 | import { ArgumentTypes } from '../src/types'; 4 | 5 | describe('trampoline', () => { 6 | describe('trampoline(fn)', () => { 7 | it('returns a tail call recursive function', () => { 8 | expect(trampoline(() => true)).toBeInstanceOf(Function); 9 | }); 10 | 11 | it('preserves argument types in the returned function', () => { 12 | const impl = (_1: number, _2: string, _3: boolean) => true; 13 | const fn = trampoline(impl); 14 | 15 | // eslint-disable-next-line @typescript-eslint/ban-types 16 | typeAssert>(true); 17 | typeAssert, [number, string, boolean]>>(true); 18 | }); 19 | 20 | it('preserves return type in the returned function', () => { 21 | const impl = () => true; 22 | const fn = trampoline(impl); 23 | 24 | // eslint-disable-next-line @typescript-eslint/ban-types 25 | typeAssert>(true); 26 | typeAssert, boolean>>(true); 27 | }); 28 | 29 | it('removes "ThunkOrValue" from the returned functions return type', () => { 30 | const impl = (): ThunkOrValue => true; 31 | const fn = trampoline(impl); 32 | 33 | // eslint-disable-next-line @typescript-eslint/ban-types 34 | typeAssert>(true); 35 | typeAssert, boolean>>(true); 36 | }); 37 | 38 | it('preserves argument types in "cont"', () => { 39 | const impl = (_1: number, _2: string, _3: boolean) => true; 40 | const { cont } = trampoline(impl); 41 | 42 | // eslint-disable-next-line @typescript-eslint/ban-types 43 | typeAssert>(true); 44 | typeAssert, [number, string, boolean]>>(true); 45 | }); 46 | 47 | it('preserves return type in "cont" returned thunk', () => { 48 | const impl = () => true; 49 | const { cont } = trampoline(impl); 50 | const thunk = cont(); 51 | 52 | // eslint-disable-next-line @typescript-eslint/ban-types 53 | typeAssert>(true); 54 | typeAssert, boolean>>(true); 55 | }); 56 | 57 | it('returns a function with a thunk returning "cont" method', () => { 58 | const fn = jest.fn((input: string) => `${input}!`); 59 | const { cont } = trampoline(fn); 60 | const thunk = cont('input'); 61 | 62 | expect(isThunk(thunk)).toBe(true); 63 | expect(fn).not.toHaveBeenCalled(); 64 | expect(thunk()).toBe('input!'); 65 | expect(fn).toHaveBeenNthCalledWith(1, 'input'); 66 | }); 67 | 68 | it("loops until the passed function doesn't return a thunk", () => { 69 | const timesToLoop = 5; 70 | const fn = trampoline((times: number = 0): ThunkOrValue => { 71 | return times < timesToLoop ? fn.cont(times + 1) : times; 72 | }); 73 | 74 | const contSpy = jest.spyOn(fn, 'cont'); 75 | fn(); 76 | expect(contSpy).toHaveBeenCalledTimes(5); 77 | }); 78 | 79 | it("doesn't throw a stack overflow error", () => { 80 | const brokenFactorial = (n: number, acc: number = 1): number => { 81 | return n ? brokenFactorial(n - 1, acc * n) : acc; 82 | }; 83 | 84 | const factorial = trampoline((n: number, acc: number = 1): ThunkOrValue => { 85 | return n ? factorial.cont(n - 1, acc * n) : acc; 86 | }); 87 | 88 | expect(() => brokenFactorial(32768)).toThrowError('Maximum call stack size exceeded'); 89 | expect(factorial(32768)).toEqual(Infinity); 90 | }); 91 | 92 | it('supports returning functions', () => { 93 | const fn = trampoline((): ThunkOrValue<() => string> => { 94 | return () => 'hello'; 95 | }); 96 | 97 | expect(fn()).toBeInstanceOf(Function); 98 | expect(fn()()).toBe('hello'); 99 | }); 100 | 101 | it('supports async functions', async () => { 102 | const sleep = async (ms: number): Promise => 103 | new Promise((resolve) => setTimeout(resolve, ms)); 104 | 105 | const factorial = trampolineAsync( 106 | async (n: number, acc: number = 1): Promise> => { 107 | await sleep(10); 108 | return n ? factorial.cont(n - 1, acc * n) : acc; 109 | }, 110 | ); 111 | 112 | expect(await factorial(2)).toBe(2); 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "outDir": "dist", 7 | "strict": true, 8 | "alwaysStrict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "esModuleInterop": true, 12 | "noImplicitReturns": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitAny": true, 16 | "noImplicitThis": true, 17 | "strictNullChecks": true, 18 | "declaration": true, 19 | "sourceMap": true, 20 | "allowJs": false, 21 | "resolveJsonModule": true, 22 | "lib": ["es2017"] 23 | }, 24 | "exclude": ["node_modules", "dist", "coverage"] 25 | } 26 | --------------------------------------------------------------------------------