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
traversewith 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 lazyvisititerator and copy-on-writetransform(Node 22+)
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.
npm install neotraverseMeasured 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 |
// 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';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.
| 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 classictraversedefault. Move classic drop-in imports toneotraverse/legacy, andimport { Traverse }toneotraverse/modern. The legacy build also targets ES2015 (was ES5); only environments needing literal ES5 (e.g. IE11) are affected.
neotraverse is safe to run on untrusted data:
-
No prototype pollution:
set(path, value)refuses__proto__/constructor/prototypekeys. -
No prototype injection:
clone()/map()of hostile JSON like{"__proto__":{"isAdmin":true}}keep their real prototype;result.isAdminisundefined. -
Prototype preservation intact: legitimate
instanceofstill 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 untouchedRead the full story in the 1.0 release post.
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/legacydrop-in is intentionally kept behaviour-compatible with the originaltraverseand does not receive them — run untrusted data through the functional API.
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/traverseThe legacy API is identical to traverse. For new code, prefer the functional default export.
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.
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/setare micro-ops (20M+ ops/s); all three builds are within noise of each other and oftraverse. 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.