neotraverse
TypeScript icon, indicating that this package has built-in type declarations

1.0.1 • Public • Published

neotraverse

Traverse and transform objects by visiting every node on a recursive walk. A TypeScript rewrite of traverse with 0 dependencies, prototype-pollution hardening, ~5× the throughput and ~6× less allocation with the functional API (up to ~10× / ~11× peaks on core walks).

📖 Full docs, security audit & live benchmarks: neotraverse.puruvj.dev

Utility-first: the default export is named functions (sideEffects: false), pay for what you use, not a monolithic class.

  • 🤌 ~2–6 KB brotli (tree-shaken; ~2 KB for one walk like forEach, ~5.8 KB for every helper)
  • 🚥 Zero dependencies, no polyfills
  • 🎹 TypeScript: throw away @types/traverse
  • 🛡️ Safe on untrusted input (prototype-pollution & injection hardened)
  • ~5× faster and ~6× leaner than traverse with the functional API (up to ~10× / ~11×); ~3× speed and ~2× less memory on the legacy drop-in build
  • 🛸 ESM-first, with a legacy ES2015 CJS/ESM build
  • 🛟 neotraverse/safe: an opt-in, stack-safe iterative core that walks 200,000-deep trees recursion can't, with a lazy visit iterator and copy-on-write transform (Node 22+)

Benchmarks (summary)

Geometric-mean speedup versus the original traverse across the full operation × shape matrix:

Build Speedup vs traverse Allocation vs traverse (core walks)
neotraverse modern (functional) ≈ 4.9× (full matrix) · ≈ 5.6× (core walks) · up to ~10× ≈ 5.7× less · up to ~11× (forEach · wide)
neotraverse legacy (drop-in) ≈ 3× ≈ 2× less

Core traversal ops (forEach / map / clone / reduce / paths / nodes) on the functional build land at ~3–10× throughput and ~6× less heap on average vs traverse (peaks: clone · small ~9.9× speed, forEach · wide ~11× memory). See the full table or the interactive benchmarks page. Reproduce with pnpm bench.

Install

npm install neotraverse

Bundle size (tree-shaken, brotli)

Measured with esbuild + brotli from published ESM (bench/bundle-sizes.json; pnpm bundle-size):

Import Brotli
One walk terminal (forEach, map, find, size, …) ~2 KB
Path only (get / has / set or getPath) ~0.3–0.5 KB
Every functional helper ~5.8 KB

Quick start

// the default export: tree-shakeable functions, `ctx` argument (recommended)
import { forEach } from 'neotraverse';

forEach({ a: 1, b: 2, c: [3, 4] }, (ctx, x) => {
  if (typeof x === 'number') ctx.update(x * 10);
});
// classic `traverse`-compatible API (drop-in for the original `traverse`)
import traverse from 'neotraverse/legacy';

traverse({ a: 1, b: 2, c: [3, 4] }).forEach(function (x) {
  if (typeof x === 'number') this.update(x * 10);
});
// the deprecated 0.x `Traverse` class lives here, removed in v2
import { Traverse } from 'neotraverse/modern';

neotraverse/safe

A second, opt-in entry point for input that is deep, untrusted, huge, or only partially consumed. The default neotraverse walk is recursive (fast, but it overflows the call stack on deep enough input). neotraverse/safe runs on an iterative engine, so it traverses arbitrarily deep trees that crash a recursive walker (measured: the default overflows past ~2,000 levels; /safe handles 200,000+). It is also lazy and copy-on-write.

import { visit, transform } from 'neotraverse/safe';

// lazy iteration: stop early, never materialize the rest
const first5 = visit(huge)
  .filter((v) => typeof v.value === 'string')
  .take(5)
  .toArray();

// copy-on-write: shares untouched subtrees, returns the input by identity on a no-op
const redacted = transform(doc, {
  '**.{password,token}': (v, { replace }) => replace('***')
});

It ships a twelve-export surface: visit, transform / transformAsync, get / set / has, clone, equal, merge, diff, patch, resolveRefs.

The honest trade-off: on a full eager scan /safe runs at roughly 0.8× the default (still ~4× faster than traverse), and materializing a whole tree costs a little more memory. It wins on stack safety, on early-exit memory (~6× less peak on a filter().take() chain), and on copy-on-write edits. Requires Node 22+ / evergreen browsers (it uses native ES2025 iterator helpers). See the /safe guide.

Builds & browser support

Build Import Contents Module Target Browsers
default neotraverse functional API (recommended) ESM ES2022 Chrome/Edge 94+, Firefox 93+, Safari 15+
safe neotraverse/safe stack-safe iterative core (opt-in) ESM ES2024 Node 22+, evergreen browsers
modern neotraverse/modern deprecated Traverse class only ESM ES2022 Chrome/Edge 94+, Firefox 93+, Safari 15+
legacy neotraverse/legacy classic traverse drop-in CJS + ESM ES2015 Chrome 51+, Firefox 54+, Safari 10+, Edge 15+

⚠️ 1.0 breaking changes: the default export (neotraverse) is now the functional API, not the classic traverse default. Move classic drop-in imports to neotraverse/legacy, and import { Traverse } to neotraverse/modern. The legacy build also targets ES2015 (was ES5); only environments needing literal ES5 (e.g. IE11) are affected.

Security

neotraverse is safe to run on untrusted data:

  • No prototype pollution: set(path, value) refuses __proto__ / constructor / prototype keys.
  • No prototype injection: clone() / map() of hostile JSON like {"__proto__":{"isAdmin":true}} keep their real prototype; result.isAdmin is undefined.
  • Prototype preservation intact: legitimate instanceof still works after a clone.
  • No prototype-chain disclosure: get() / has() follow only own properties.
import { clone } from 'neotraverse';

const evil = JSON.parse('{"user":"bob","__proto__":{"isAdmin":true}}');
clone(evil).isAdmin; // undefined
({}).isAdmin; // undefined, global prototype untouched

Read the full story in the 1.0 release post.

DoS guard

Bound recursion on deeply-nested hostile input with maxDepth (throws a catchable RangeError; unlimited by default):

clone(untrusted, { maxDepth: 1000 });

The hardening and DoS guards apply to the functional API. The neotraverse/legacy drop-in is intentionally kept behaviour-compatible with the original traverse and does not receive them — run untrusted data through the functional API.

Migrating from traverse

Differences from traverse: drop-in vs functional, what's new, which build to use.

-import traverse from 'traverse';
+import traverse from 'neotraverse/legacy';
npm install neotraverse && npm uninstall traverse @types/traverse

The legacy API is identical to traverse. For new code, prefer the functional default export.

API

Functional (neotraverse, recommended): forEach, map, clone, reduce, find, filter, some, every, paths, nodes, get, set, has, entries, values, walk, breadthFirst, mapBfs, forEachAsync, mapAsync, plus findPaths, filterPaths, getPath, setPath, hasPath, parsePath, parseDotPath, parseJsonPointer, pointerPath, parseGlob, count, size, getType, groupBy, skipWhere, deleteWhere, prune, pruneDeep, sanitize, deepEqual, merge, dereference, toJSON, freeze, diff, patch, select. Options are always the last argument.

Deprecated class (neotraverse/modern): the Traverse class (.get/.has/.set/.map/.forEach/.reduce/.paths/.nodes/.clone) is deprecated and removed in v2 — use the functions above.

Classic (neotraverse/legacy): .map(fn) · .forEach(fn) · .reduce(fn, acc) · .paths() · .nodes() · .clone() · .get(path) · .set(path, value) · .has(path) on a traversal instance (this-bound context).

Options: { immutable?, includeSymbols?, maxDepth?, signal? } (signal is async-only on modern).

Each callback gets a context (ctx in modern, this in classic) with node, path, parent, key, isRoot, isLeaf, isFirst, isLast, level, circular, and the mutators update(), remove(), delete(), before(), after(), pre(), post(), stop(), block().

👉 Full API reference, examples, and context docs: neotraverse.puruvj.dev/guide.

Benchmarks (full) {#benchmarks-full}

neotraverse vs traverse, ops/sec (and ×speedup). Generated by bench/run.ts via tinybench; see bench/results.json.

Operation · shape traverse neotraverse legacy neotraverse modern
forEach · small 841,043 2,414,704 (2.87×) 4,016,013 (4.78×)
forEach · wide 79,308 284,208 (3.58×) 594,241 (7.49×)
forEach · deep 53,214 160,316 (3.01×) 330,800 (6.22×)
forEach · array 3,019 10,186 (3.37×) 21,242 (7.04×)
forEach · json 57,421 186,579 (3.25×) 382,143 (6.66×)
map · small 393,442 1,268,242 (3.22×) 2,267,588 (5.76×)
map · wide 50,468 106,498 (2.11×) 183,870 (3.64×)
map · deep 18,833 87,198 (4.63×) 136,623 (7.25×)
map · array 1,421 4,376 (3.08×) 8,619 (6.07×)
map · json 23,178 83,958 (3.62×) 151,069 (6.52×)
clone · small 494,252 3,356,979 (6.79×) 4,884,062 (9.88×)
clone · wide 209,591 309,111 (1.47×) 574,325 (2.74×)
clone · deep 27,853 170,807 (6.13×) 260,473 (9.35×)
clone · array 3,250 11,408 (3.51×) 15,411 (4.74×)
clone · json 42,207 205,740 (4.87×) 303,054 (7.18×)
reduce · small 796,994 2,201,209 (2.76×) 3,536,758 (4.44×)
reduce · wide 75,627 260,493 (3.44×) 489,665 (6.47×)
reduce · deep 50,668 145,498 (2.87×) 288,933 (5.7×)
reduce · array 2,909 9,229 (3.17×) 17,781 (6.11×)
reduce · json 55,255 173,837 (3.15×) 328,782 (5.95×)
paths · small 811,923 2,279,936 (2.81×) 3,402,905 (4.19×)
paths · wide 77,412 268,188 (3.46×) 475,490 (6.14×)
paths · deep 50,955 151,982 (2.98×) 212,666 (4.17×)
paths · array 2,976 9,566 (3.21×) 16,368 (5.5×)
paths · json 56,251 177,158 (3.15×) 294,870 (5.24×)
nodes · small 800,765 2,258,624 (2.82×) 3,490,623 (4.36×)
nodes · wide 76,618 265,446 (3.46×) 506,638 (6.61×)
nodes · deep 51,151 149,846 (2.93×) 288,099 (5.63×)
nodes · array 2,943 9,442 (3.21×) 17,715 (6.02×)
nodes · json 55,541 176,656 (3.18×) 328,321 (5.91×)
get · json 18,069,492 22,168,643 (1.23×) 21,280,988 (1.18×)
has · json 18,943,414 22,254,349 (1.17×) 22,156,236 (1.17×)
set · json 23,632,276 23,862,298 (1.01×) 19,365,527 (0.82×)

get / has / set are micro-ops (20M+ ops/s); all three builds are within noise of each other and of traverse. The legacy class stores its state in plain instance fields (not #private, which would downlevel to WeakMaps at ES2015), so these stay native-fast on the drop-in too.

License

MIT, Puru Vijay.