neotraverse
Version:
traverse and transform objects by visiting every node on a recursive walk
206 lines (148 loc) • 13.2 kB
Markdown
# neotraverse
Traverse and transform objects by visiting every node on a recursive walk. A TypeScript rewrite of [`traverse`](https://github.com/ljharb/js-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](https://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](#security))
- ⚡ **~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`](#neotraversesafe):** 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](#benchmarks-full) or the [interactive benchmarks page](https://neotraverse.puruvj.dev/benchmarks). Reproduce with `pnpm bench`.
## Install
```sh
npm install neotraverse
```
## Bundle size (tree-shaken, brotli)
Measured with esbuild + brotli from published ESM ([`bench/bundle-sizes.json`](./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
```ts
// 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);
});
```
```ts
// 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);
});
```
```ts
// 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.
```ts
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](https://neotraverse.puruvj.dev/guide/safe).
## 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.
```ts
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**](https://puruvj.dev/blog/neotraverse-1-0).
### DoS guard
Bound recursion on deeply-nested hostile input with `maxDepth` (throws a catchable `RangeError`; unlimited by default):
```ts
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](https://neotraverse.puruvj.dev/guide/vs-traverse):** drop-in vs functional, what's new, which build to use.
```diff
-import traverse from 'traverse';
+import traverse from 'neotraverse/legacy';
```
```sh
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](https://neotraverse.puruvj.dev/guide)**.
## Benchmarks (full) {#benchmarks-full}
`neotraverse` vs `traverse`, ops/sec (and ×speedup). Generated by [`bench/run.ts`](./bench/run.ts) via [tinybench](https://github.com/tinylibs/tinybench); see [`bench/results.json`](./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](./LICENSE), [Puru Vijay](https://puruvj.dev).