Skip to content

Replace ts-toolbelt with local type utilities - #149

Open
connorshea wants to merge 2 commits into
ramda:developfrom
connorshea:perf/deep-import-ts-toolbelt
Open

Replace ts-toolbelt with local type utilities#149
connorshea wants to merge 2 commits into
ramda:developfrom
connorshea:perf/deep-import-ts-toolbelt

Conversation

@connorshea

@connorshea connorshea commented Aug 18, 2026

Copy link
Copy Markdown

Fixes #148.

AI Disclosure: Generated with Claude Code, Opus 5. Reviewed and tested by me, added a bunch of tests where there weren't any to ensure no regressions. I'm happy to split this into smaller pieces if needed (e.g. add the tests in one PR, then add port the local type utils, or split the type util ports into smaller sets rather than one big PR).


ts-toolbelt's entry point is a namespace barrel: out/index.d.ts eagerly imports 13 _api.d.ts
namespaces, so all 241 of its files are pulled into the program of every project that consumes
these types. skipLibCheck hides the type-checking cost but not the parse and bind cost.

Most of that cost is one type. Iteration/IterationMap is a 202-entry lookup table (-100..100)
whose elements are typed keyof IterationMap — a 202-member literal union — because ts-toolbelt
predates TypeScript 4.5 and had to count without variadic tuples.

Measured on a project importing a single ramda function:

files memory type instantiations
before 253 92 MB 7,309
after 13 73 MB 3,441

(The 19-function benchmark shows the same shape; instantiations roughly halve once the Iteration
table is gone.) With skipLibCheck: false the check time for the barrel alone was 0.35s and
57,135 types.

What

The twelve types this package actually needed from ts-toolbelt are reimplemented in
types/util/_internal.d.ts, recursing over variadic tuples rather than counting — so there is no
arithmetic and no ±100 element cap.

package.json now has no dependencies block at all; the package has zero runtime
dependencies.

A util file whose name starts with _ is internal: npm run build imports from it and ships it
alongside the other declarations, but does not re-export it, so none of this widens the public API.
That convention is documented in CONTRIBUTING.md and implemented in scripts/buildScripts.mjs.

The generated es/index.d.ts differs from the previous build only in its import header and in
three renames (xPlaceholderBrand, F.Parameters → the global Parameters, F.Return
ReturnType). Every exported declaration keeps its shape.

How it was checked

  • A differential battery type-checks the new implementations against ts-toolbelt's originals for
    invariant type identity — (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2)
    — plus separate probes for inference behaviour, which identity does not capture. That mattered:
    writing Narrow as A extends [] ? A : NarrowRaw<A> produces an identical type but defers the
    conditional, and a deferred conditional is not an inference site, so ['a', 'b'] widened to
    string[]. Routing through the Try indirection is load-bearing and is commented as such.
  • A mutation sweep over types/util/_internal.d.ts — break one behaviour, run
    build + typecheck + tsd, restore — initially left 13 of 17 mutations alive, because
    curry, curryN, binary, nAry, flatten, unnest, mergeDeepLeft and mergeDeepRight
    had no test files at all. That sweep found a genuine bug: Flatten recursed structurally into
    each element instead of un-nesting to a fixpoint, so flatten(x: number[][][]) returned
    number[][] and flatten(x: [number[][], string]) silently dropped the array. Fixed in the last
    commit, along with tests that bring the survivors down to two.
  • The two remaining survivors are unobservable through the public API and are kept for fidelity
    with the original: Primitive's bigint (the only branch it gates produces a never
    intersection either way) and MergeProp's optional-key branch (an optional key's type always
    includes undefined, so the OK extends undefined fallback covers it).
  • ts4/es/index.d.ts compiles clean under a real TypeScript 4.9.

npm run build, npm run typecheck, npm run lint and npx tsd (73 files) are all green.

🤖 Generated with Claude Code

connorshea and others added 2 commits August 17, 2026 19:03
ramda used twelve types from ts-toolbelt, but its entry point is a
namespace barrel that drags all 241 of its files into the program of
every project that consumes these types. Deep-importing helped; not
depending on it at all helps more, and the twelve are not much code.

types/util/_internal.d.ts now implements them. A util file whose name
starts with `_` is internal: the build imports from it and ships it, but
does not re-export it, so the public API is untouched. Two of the twelve
turned out to be TypeScript globals already (`F.Parameters` is
`Parameters`, `F.Return` is `ReturnType`), and `T.Merge` was only ever
used to swap the first two parameters in `flip`, which `Swap2` now says
directly.

ts-toolbelt predates TypeScript 4.5, so it counts with a 202-entry lookup
table of number literals (`Iteration`) that also caps it at +/-100
elements. The replacements recurse over variadic tuples, so there is no
arithmetic, no table, and no upper bound. That is where the drop in
instantiations comes from - same results, less work.

Measured on a program that does `import * as R from 'types-ramda'` and
calls 19 of the exported functions, TypeScript 5.2:

                    before   deep imports   no ts-toolbelt
  Files                253             66               13
  Memory             92 MB          81 MB            73 MB
  Instantiations     7,309          7,309            3,441

Verified by a differential harness that asserts each replacement is the
*same type* as the ts-toolbelt original - invariant identity, not mutual
assignability - across 60 cases covering optional and rest parameters,
out-of-range indices, deep merges, arrays, tuples, Date and functions.
`Narrow` additionally gets inference probes, because identity alone did
not catch that writing its conditional inline instead of routing it
through `Try` silently defers it, stops it being an inference site, and
widens `['a', 'b']` to `string[]`. `Curry` is checked behaviourally
across 24 applications, placeholders included, since two distinct
recursive aliases can never compare equal by identity.

The generated es/index.d.ts differs from the previous build only in its
import header and the renamed references; every declaration is
unchanged. tsd passes clean, and ts4/es/index.d.ts still compiles clean
under TypeScript 4.9.

The package now has no runtime dependencies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Flatten had to un-nest to a fixpoint rather than recurse into each element:
a tuple can hold a plain array (`[number[][], string]`), and un-nesting that
degrades the whole thing to an array which still has levels left to remove.
Recursing per element stopped early on `number[][][]` and dropped the array
out of the tuple entirely.

Nothing caught that, because twelve of the utilities this package took from
ts-toolbelt had no tests reaching them. A mutation sweep over
`types/util/_internal.d.ts` - break one behaviour, run the suite - left 13 of
17 mutations alive. Add tests for curry, curryN, binary, nAry, flatten,
unnest, mergeDeepLeft and mergeDeepRight, and extend the flip, mergeLeft and
zipObj tests, which brings the survivors down to two:

  - `Primitive`'s `bigint`: the only branch it gates produces a `never`
    intersection either way.
  - `MergeProp`'s optional-key branch: an optional key's type always includes
    `undefined`, so the `OK extends undefined` fallback covers it.

Both are unobservable through the public API and kept for fidelity with the
original.

Also drop `TakeFirst`'s `number extends N` guard: `Acc` starts empty and
`0 extends number`, so the very next conditional already returns `[]`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@connorshea
connorshea force-pushed the perf/deep-import-ts-toolbelt branch from 103f4ec to 6e47db6 Compare August 18, 2026 01:04
@connorshea
connorshea marked this pull request as ready for review August 18, 2026 01:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Vendor ts-toolbelt types and drop dependency

1 participant