neotraverse/safe
A second, opt-in traversal core. It's a companion to the default API, not a replacement. The default neotraverse is the fast path: a recursive walk ~5× faster than the original traverse. neotraverse/safe makes a different trade (stack-safe, memory-bounded, pollution-safe, at a modest throughput cost) and ships a different, smaller surface built around a lazy iterator and a copy-on-write rewriter.
import {
visit,
transform,
transformAsync, // traversal
get,
set,
has, // paths
clone,
equal,
merge,
diff,
patch, // structural ops
resolveRefs // $ref resolution
} from 'neotraverse/safe';Reach for it when the input is deep, huge, untrusted, or only partially consumed. Stay on the default when you're doing a full eager pass over trusted, bounded data and want maximum speed.
Why it exists
The default walk is recursive, which is why it's fast. But recursion means a deep enough tree overflows the JavaScript call stack and throws RangeError: Maximum call stack size exceeded, and there's no way around that for a recursive walker. neotraverse/safe runs on an iterative engine (an explicit stack on the heap), so it walks arbitrarily deep input the default (and almost every other tree library) simply can't. On top of that it's lazy (stream and early-exit without materializing intermediates) and its writes are copy-on-write (share untouched subtrees instead of deep-copying). Three concrete wins, measured:
- Stack safety. The recursive default overflows past ~2,000 levels;
/safehandles 200,000+. Categorical, not incremental. - Bounded memory on partial consumption. An early-exit chain peaks at ~6× less memory than the eager default, because it never builds the intermediate array.
- Copy-on-write edits. A sparse edit copies only the changed spine, and a no-op returns the input by identity.
Jump to the honest trade-offs ↓. /safe is not a free upgrade.
visit: lazy iteration
function visit<T>(root: T, options?: VisitOptions): Visits;
function visit<T>(root: T, pattern: string, options?): Visits; // positional `match` shorthandvisit returns a lazy iterator of Visit records, one per node, that subclasses the native ES2025 Iterator. Every helper (.map, .filter, .find, .take, .drop, .toArray, .reduce, .some, .every, plus for…of, spread, and Map.groupBy) works with zero extra bundle cost.
for (const v of visit(doc)) {
if (v.key === 'node_modules') {
v.skip();
continue;
} // don't descend
if (v.value === target) {
console.log(v.pointer);
break;
} // '/users/3/email'
}
visit(doc).find((v) => v.value === needle)?.path; // ['users', 3, 'email']
visit(doc)
.filter((v) => v.isLeaf)
.map((v) => v.value)
.toArray();
Map.groupBy(visit(doc), (v) => typeof v.value);Each Visit is a fresh, retention-safe record:
| field | meaning |
|---|---|
value | the node's value |
key | its key in the parent (undefined at the root) |
parent | the parent node's Visit (undefined at the root) |
depth | depth from the root (0 at the root) |
path | key path from the root, a lazy getter (you pay only when you read it) |
pointer | RFC 6901 JSON Pointer string, also lazy |
isLeaf | no traversable children under the current options |
circular | the ancestor Visit this node points back to, if it's a back-edge |
skip() | don't descend into this node (pre/breadth order; throws in post) |
Options (shared, where they make sense, with transform and the ops):
interface VisitOptions {
order?: 'pre' | 'post' | 'breadth'; // default 'pre' (parents first)
match?: string; // glob pattern; yields only matches, PRUNES descent
symbols?: boolean; // include own enumerable symbol keys (default false)
mapSet?: boolean; // descend into Map/Set entries, recursively (default false)
maxDepth?: number; // catchable RangeError past the bound
}The match pattern ('users.*.email', '**.id') prunes the walk to viable prefixes, so subtrees that can't contain a match are never entered. Grammar: literal, * (one segment), ** (recursive descent), {a,b,c} (alternation), with \ escapes.
visit(doc, '**.email')
.map((v) => v.value)
.toArray(); // every `email` at any depth, prunedtransform: copy-on-write rewrites
function transform<T>(root: T, visitor: Visitor | Visitor[] | Rules, options?: TransformOptions): T;transform rewrites a tree by copying only the spine along each edit and sharing every untouched subtree with the input (the Immer model, no proxies). A visitor edits by returning a command from its second argument, an edit factory you can destructure:
const next = transform(state, (v, { replace, remove }) => {
if (v.key === 'password') return replace('[redacted]');
if (v.key === 'internalId') return remove();
});
next === state; // true if nothing matched. Identity means "no change"
next.profile === state.profile; // true. Untouched subtree shared by reference| return | meaning |
|---|---|
undefined / nothing | keep the value, descend into it |
replace(value) | substitute. Final: not re-visited, not descended. replace(undefined) sets undefined |
replace(value, { descend: true }) | substitute, then descend into the replacement's children |
remove() | delete the entry (arrays splice, no holes). TypeError at the root |
skip() | keep the value, don't descend |
stop() | end the pass. Edits already made still fold up |
Returning anything else throws a TypeError (and is a compile error in TS, since commands are branded).
Record rules are the headline form: N pattern-scoped rules fused into one descent-pruned pass.
const safe = transform(doc, {
'**.{password,token,secret}': (v, { replace }) => replace('[redacted]'),
'**.url': (v, { replace }) => replace(String(v.value).replace(/^http:/, 'https:'))
});Other knobs: order: 'post' runs the visitor on already-folded children; { mutate: true } edits in place and returns the root; match scopes a single/array visitor (a TypeError with the rules form). transformAsync is the async twin, with await-able visitors, concurrency for parallel siblings, and the only function that accepts an AbortSignal.
get / set / has: paths
One path family, three spellings: a dot string ('users.0.name'), a JSON Pointer ('/users/0/name'), or an exact key array (['users', 0, 'name']). Map keys and Set indices are navigated natively.
get(cfg, 'server.port'); // typed: number | undefined
get(cfg, 'server.host', 'localhost'); // fallback ONLY when the path is absent (not present-undefined)
has(cfg, ['server', 'tls']);
const next = set(cfg, 'server.port', 8080); // copy-on-write: new root, cfg untouched, siblings shared
set(cfg, 'server.port', 8080, { mutate: true }); // in-place, returns the rootset is copy-on-write by default and returns the new root (and returns the input by identity when the value is unchanged). String paths are template-literal typed (get infers number | undefined from 'server.port'), with a graceful unknown for dynamic strings. Writes through __proto__ / constructor / prototype throw, fail-closed.
Structural ops
clone(value); // deep, cycle- & prototype-preserving. The "make independent" verb
equal(a, b); // structural equality (SameValueZero, Map/Set/typed-arrays/Date/…)
merge(base, overlay, opts); // deep merge, shares kept branches; array strategies + per-pattern `at`
diff(a, b); // RFC 6902 ops (add/remove/replace); throws on cycles; undo is diff(b, a)
patch(value, ops); // apply RFC 6902 copy-on-write; patch(x, []) === x
resolveRefs(doc); // resolve local '#/' $ref objects on a shared-structure resultmerge answers the one question real-world merging dies on (what happens to arrays?) declaratively:
merge(defaults, userConfig, {
arrays: 'replace', // default
at: { plugins: { by: 'name' }, '**.tags': 'union' } // per-path overrides
});Cycles in copy-on-write edits (a known limitation)
transform and merge copy only the spine along each change and share everything else. For cyclic inputs this has one deliberate limitation: a back-edge in the output still points at the original ancestor, not the rewritten copy. Rewiring cycles during a copy-on-write fold needs complex after-the-fact patching for a rare input, so it's intentionally out of scope.
const cyc = { x: { a: 1 } };
cyc.self = cyc;
const out = transform(cyc, (v, { replace }) => (v.key === 'a' ? replace(2) : undefined));
out.x.a; // 2 (the edited spine is copied)
out.self === out; // false (the back-edge still points at the ORIGINAL cyc)When you need a cycle-preserving rewrite, edit a clone in place (no fold, so no back-edges to rewire): transform(clone(cyc), f, { mutate: true }). diff takes the stricter line and throws on any cyclic input.
The honest trade-offs
neotraverse/safe is not a universal upgrade:
Default neotraverse | neotraverse/safe | |
|---|---|---|
| Full eager scan throughput | 1.0× | ~0.8× (still ~4× faster than traverse) |
| Memory on a full materialize | lower | ~1.7× higher (one Visit per node) |
| Deep input (>~2k levels) | 💥 RangeError | ✅ traverses fine |
| Early-exit / streaming memory | higher | ~6× lower |
| Bundle size | ~5.8 KB | ~8 KB brotli |
Two caveats:
Full scans aren't a memory win.
/safeallocates oneVisitper node (about the same as the default's per-node context). Keeping the whole tree costs more; the advantage is specifically about not materializing what you don't consume.Visitrecords retain their ancestor chain..pathis lazy viav.parent, so a retainedVisitkeeps its ancestors, including the root, whose.valueis the whole input. To let a huge input be collected, extractv.value/v.pathand drop theVisit:tsconst values = visit(huge) .filter(p) .map((v) => v.value) .toArray(); // keeps only values
When to use which
- Default
neotraverse. Trusted, bounded-depth data; full eager passes; maximum speed; smallest bundle. neotraverse/safe. Untrusted or unknown-depth input; very deep trees; streaming / early-exit over large data; copy-on-write edits; anywhere a stack overflow would be a correctness or security bug.
See Benchmarks for the full throughput matrix.
Platform
neotraverse/safe requires Node 22+ / evergreen browsers (it uses native ES2025 iterator helpers). The default neotraverse has no such floor.