Replace ts-toolbelt with local type utilities - #149
Open
connorshea wants to merge 2 commits into
Open
Conversation
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
force-pushed
the
perf/deep-import-ts-toolbelt
branch
from
August 18, 2026 01:04
103f4ec to
6e47db6
Compare
connorshea
marked this pull request as ready for review
August 18, 2026 01:12
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tseagerly imports 13_api.d.tsnamespaces, so all 241 of its files are pulled into the program of every project that consumes
these types.
skipLibCheckhides the type-checking cost but not the parse and bind cost.Most of that cost is one type.
Iteration/IterationMapis a 202-entry lookup table (-100..100)whose elements are typed
keyof IterationMap— a 202-member literal union — becausets-toolbeltpredates TypeScript 4.5 and had to count without variadic tuples.
Measured on a project importing a single ramda function:
(The 19-function benchmark shows the same shape; instantiations roughly halve once the
Iterationtable is gone.) With
skipLibCheck: falsethe check time for the barrel alone was 0.35s and57,135 types.
What
The twelve types this package actually needed from
ts-toolbeltare reimplemented intypes/util/_internal.d.ts, recursing over variadic tuples rather than counting — so there is noarithmetic and no ±100 element cap.
package.jsonnow has nodependenciesblock at all; the package has zero runtimedependencies.
A util file whose name starts with
_is internal:npm run buildimports from it and ships italongside the other declarations, but does not re-export it, so none of this widens the public API.
That convention is documented in
CONTRIBUTING.mdand implemented inscripts/buildScripts.mjs.The generated
es/index.d.tsdiffers from the previous build only in its import header and inthree renames (
x→PlaceholderBrand,F.Parameters→ the globalParameters,F.Return→ReturnType). Every exported declaration keeps its shape.How it was checked
ts-toolbelt's originals forinvariant 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
NarrowasA extends [] ? A : NarrowRaw<A>produces an identical type but defers theconditional, and a deferred conditional is not an inference site, so
['a', 'b']widened tostring[]. Routing through theTryindirection is load-bearing and is commented as such.types/util/_internal.d.ts— break one behaviour, runbuild+typecheck+tsd, restore — initially left 13 of 17 mutations alive, becausecurry,curryN,binary,nAry,flatten,unnest,mergeDeepLeftandmergeDeepRighthad no test files at all. That sweep found a genuine bug:
Flattenrecursed structurally intoeach element instead of un-nesting to a fixpoint, so
flatten(x: number[][][])returnednumber[][]andflatten(x: [number[][], string])silently dropped the array. Fixed in the lastcommit, along with tests that bring the survivors down to two.
with the original:
Primitive'sbigint(the only branch it gates produces aneverintersection either way) and
MergeProp's optional-key branch (an optional key's type alwaysincludes
undefined, so theOK extends undefinedfallback covers it).ts4/es/index.d.tscompiles clean under a real TypeScript 4.9.npm run build,npm run typecheck,npm run lintandnpx tsd(73 files) are all green.🤖 Generated with Claude Code