refactor: remove ts-toolbelt dependency#53
Conversation
Replace all ts-toolbelt utilities with native TypeScript built-ins and custom implementations while maintaining 100% type safety. Changes: - Create src/types/utils.ts with custom type utilities (Primitive, BuiltIn, List, Cast, Keys, Compute, Split, Join, Path, FilterNever, NonNullableFlat, RequireOnlyOne) - Replace ts-toolbelt imports in paths.ts, required.ts, evaluator.ts, and expressionParts.ts - Replace Any.Key with PropertyKey in helpers.ts - Add type assertion in engine.ts for RuleDefinition narrowing - Update test file to use custom Compute type - Remove ts-toolbelt from dependencies Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
❌ 4 blocking issues (4 total)
|
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (2)
🛟 Help
|
…add type benchmark
Follow-up review of the ts-toolbelt removal, focused on correctness, exact
behavioural parity with the old ts-toolbelt types, and type-check performance.
- Path: add a materialization boundary (`_Path` + `_Path<..> extends infer X ? X`)
so relating two `Path<>` instantiations resolves to concrete leaves instead of
recursing structurally through nested `_Path`. Fixes TS2321 "Excessive stack
depth comparing types 'Path<?, P>'". A cross-context `or` that the ts-toolbelt
types compiled at ~4.1M instantiations / TS2321 now compiles at ~239k.
- required.ts NonNullable (deep): strip null/undefined *before* the `extends
object` guard, so nullables under `SomeObject | undefined` are deep-stripped
again (a regression vs the released ts-toolbelt behaviour). Guarded by
src/test/types/validationContext.test-d.ts.
- utils.ts: drop the erased `& {}` / `& unknown` "prettify" intersections flagged
by radarlint (S4335 / S6571). RequireOnlyOne keeps a bare homomorphic
mapped-type prettify, which is structurally identical to ts-toolbelt's
Object.Either (strict) while staying lint-clean.
- paths.ts: drop the `NonNullableFlat<[...P, k]>` wrapper (a tuple of PropertyKeys
is never nullable, so it was a no-op) and the now-unused NonNullableFlat export.
- Add a cross-platform type-instantiation benchmark (src/test/benchmark.spec.ts)
that compiles a heavy fixture with `tsc --extendedDiagnostics` and fails on
excessive-depth errors or if instantiations regress past budget.
Verified structurally identical to the ts-toolbelt output across a battery of
contexts (Expression / ExpressionParts / ValidationContext / PropertyCompareOps /
StringPaths / Paths / Rule) and every removed utility. lint, compile, tsd and
100% coverage all pass.
🤖 Generated with [Claude Code](https://claude.ai/code)
Follow-up review: type correctness, exact parity & TypeScript performancePushed as TL;DR — TypeScript performance (identical cross-context
|
| Metric | OLD (ts-toolbelt) |
NEW (this branch) | Δ |
|---|---|---|---|
| Result | ❌ TS2321 (excessive depth) | ✅ compiles | — |
| Types | 1,018,577 | 61,698 | ~16.5× fewer |
| Instantiations | 4,147,372 | 238,969 | ~17.4× fewer |
| Memory | ~1,100 MB | ~216 MB | ~5× less |
| Check time | 9.77 s | 1.00 s | ~10× faster |
The TS2321: Excessive stack depth comparing types 'Path<?, P>' that surfaced downstream is reproduced by the old ts-toolbelt Object.Path — it was not introduced here; this branch removes it.
Root cause & fix — Path
ts-toolbelt's Object.Path materialised its result (_Path<..> extends infer X ? Cast<X,any>). The first cut of utils.ts dropped that boundary, so relating two Path<> instances recursed structurally through every nested step → TS2321 / TS2589 on deep contexts. Restored a materialization boundary:
type _Path<O, P extends readonly PropertyKey[]> =
P extends [] ? O
: P extends [infer H extends PropertyKey, ...infer T extends PropertyKey[]] ? _Path<At<O, H>, T>
: never;
export type Path<O, P extends readonly PropertyKey[]> = _Path<O, P> extends infer X ? X : never;I also measured the
ts-toolbelt-style object-indexed iteration ({0,1}[Extends<..>]) and a per-keyPathcache — both were worse or neutral (the iteration form reproduces TS2321 at ~2M instantiations; manual caching doesn't help because TS already memoises identical instantiations). The plain destructuring + materialization above is the fastest form that stays exact.
Correctness fix — deep NonNullable (ValidationContext)
The rewrite guarded recursion with O[K] extends object, which is false for SomeObject | undefined, so nullables under an optional/nullable object property were left un-stripped (a regression vs the released ts-toolbelt behaviour). Fixed by stripping null before the guard, and added src/test/types/validationContext.test-d.ts (fails on the old code, passes now).
Bot comments (radarlint S4335 / S6571) — resolved
The 4 flagged & {} / & unknown intersections are erased no-ops. Removed them; RequireOnlyOne keeps a bare homomorphic mapped-type prettify that is structurally identical to ts-toolbelt's Object.Either (strict) while staying lint-clean.
ts-toolbelt → native: old vs new (all verified Equal)
Removed ts-toolbelt usage |
Replacement | Verified |
|---|---|---|
Any.Key |
PropertyKey |
✅ exact |
Any.Cast |
Cast |
✅ exact |
Any.Keys |
Keys |
✅ exact |
Any.Compute (ComputeRaw) |
Compute (bare mapped type) |
✅ exact |
List.List |
List |
✅ exact |
List.Length<P> |
P['length'] |
✅ exact |
Misc.BuiltIn |
BuiltIn |
✅ exact |
Misc.Primitive |
Primitive |
✅ exact |
Union.Exclude |
Exclude (native) |
✅ exact |
Union.NonNullable |
NonNullable / globalThis.NonNullable |
✅ exact |
Function.Parameters |
Parameters (native) |
✅ exact |
String.Split / String.Join |
Split / Join |
✅ exact |
Object.Path |
Path (materialized) |
✅ exact |
Object.Either |
RequireOnlyOne |
✅ exact |
Object.Filter<_, never, 'equals'> |
FilterNever |
✅ exact |
Object.NonNullable NonNullableFlat |
(removed — was a no-op on key tuples) | n/a |
Parity method: reinstalled ts-toolbelt locally and asserted Equal<Old, New> (invariant identity, not just assignability) for the whole public surface — Expression, ExpressionParts, ValidationContext, PropertyCompareOps, StringPaths, Paths, Rule — across a battery of contexts (optional/nullable objects, arrays, tuples, Record index signatures, readonly, any/unknown, union objects, depth‑11 chains, Moment+Ignore, empty/single‑key). All identical. (ts-toolbelt is not re-added as a dependency.)
Extra optimisation
paths.ts wrapped every path leaf in NonNullableFlat<[...P, k]>; the tuple is PropertyKeys (never nullable), so it was a no-op. Dropped it (and the now-unused export) — small per-leaf instantiation + memory saving, output unchanged.
Regression guard — src/test/benchmark.spec.ts
New cross-platform benchmark: compiles a heavy fixture with tsc --extendedDiagnostics and fails if there's any excessive-depth error or instantiations regress past budget. Reverting Path to a ts-toolbelt-style encoding trips it (TS2321 + ~2M instantiations); current baseline ~321k.
Verification
lint, compile, tsd (incl. new regression test), 100% coverage (167 tests), and pnpm run ci all pass.
🤖 Generated with Claude Code
|
@qltysh resume |

Summary
ts-toolbeltdependency by replacing all utilities with native TypeScript built-ins and custom implementationsChanges
New file:
src/types/utils.tsCustom type utilities replacing ts-toolbelt:
Primitive,BuiltIn- Type categoriesList,Cast,Keys,Compute- Any/list utilitiesSplit,Join- String utilitiesPath,FilterNever,NonNullableFlat- Object utilitiesRequireOnlyOne- Replacement forObject.EitherModified files
src/types/paths.ts./utilssrc/types/required.tsMisc.BuiltIn→BuiltInsrc/types/evaluator.tsRequireOnlyOnesrc/types/expressionParts.tsParameterssrc/lib/helpers.tsAny.Key→PropertyKeysrc/lib/engine.tsRuleDefinitionnarrowingsrc/test/types/expressionParts.tsAny.Compute→Computepackage.jsonts-toolbeltdependencyTest plan
pnpm run ci)🤖 Generated with Claude Code