neotraverse

repository·main·Indexed 42 days ago

https://github.com/puruvj/neotraverse

A high-performance, zero-dependency TypeScript library for recursive object traversal and transformation. Designed as a modern replacement for the traverse package, it offers a tree-shakeable functional API, a stack-safe iterative core via `neotraverse/safe` for extremely deep input, and a legacy drop-in build for compatibility. It includes built-in protections against prototype pollution and injection, as well as support for asynchronous traversals with concurrency control and AbortSignal integration.

Tokens
39.9K
Snippets
121
Records
166
Agent score
53%

What's inside neotraverse

  1. Overview of neotraverse features

    main

    neotraverse is a high-performance, zero-dependency library designed for traversing and transforming objects. It is designed as a faster, leaner, and more secure alternative to traverse.

    Key features include:

    • Tree-shakeable: Utility-first named imports allow for minimal bundle sizes (~2 KB brotli for forEach, up to ~6 KB for the full functional API).
    • Hardened Security: Protects against prototype pollution and injection on untrusted input, with an optional maxDepth DoS guard.
    • High Performance: The functional API provides significantly higher throughput and lower heap usage compared to traverse.
    • TypeScript-first: Built-in type definitions (no need for @types/traverse).
    • Environment Support: ESM-first with legacy ES2015 CJS/ESM support. Works in Node, Deno, and browsers.
  2. When to use neotraverse/safe vs default neotraverse

    main

    The default neotraverse is a fast, recursive walker optimized for speed (approx. 5x faster than the original traverse). Use it for full, eager passes over trusted, bounded data.

    neotraverse/safe is a companion API designed for different constraints. Use it when your input is:

    • Deep: It uses an iterative engine (heap-based stack) to handle depths of 200,000+ levels, whereas the default overflows at ~2,000 levels.
    • Huge/Untrusted: It is memory-bounded for partial consumption (up to 6x less memory when using early-exit/streaming).
    • Partially Consumed: It provides a lazy iterator that doesn't materialize intermediate arrays.
    • Requiring Immutable Edits: It uses a copy-on-write (Immer-style) model that shares untouched subtrees.
  3. Handling cycles in copy-on-write edits

    main

    A known limitation of transform and merge is how they handle cyclic inputs. Because they copy only the "spine" of the tree, a back-edge in the output will still point to the original ancestor in the input, rather than the newly rewritten copy.

    Example of the limitation: If you have a cyclic object cyc and use transform to edit a value, out.self === out will be false because the back-edge still points to the original cyc.

    Workaround: If you require a cycle-preserving rewrite, you must first clone the object and then perform an in-place mutation: transform(clone(cyc), visitor, { mutate: true }).

    // The limitation:
    const cyc = { x: { a: 1 } };
    cyc.self = cyc;
    
    const out = transform(cyc, (v, { replace }) => (v.key === 'a' ? replace(2) : undefined));
    console.log(out.x.a); // 2
    console.log(out.self === out); // false (points to original cyc)
    
    // The workaround:
    const fixed = transform(clone(cyc), (v, { replace }) => (v.key === 'a' ? replace(2) : undefined), { mutate: true });
    console.log(fixed.self === fixed); // true
  4. How to descend into Map and Set entries

    main

    By default, neotraverse treats Map and Set objects as leaf nodes during lazy iteration, t.forEach, t.map, t.paths, and t.nodes. This means the traversal stops at the Map/Set and does not visit their contents.

    To visit Map values (at their keys) and Set elements (at numeric indices) during a walk, you must pass the option { descendIntoMapSet: true } to the traversal function.

    import * as t from 'neotraverse';
    
    const m = new Map([['k', { v: 1 }]]);
    const paths: PropertyKey[][] = [];
    
    // Without descendIntoMapSet: true, this only yields the Map itself
    // With descendIntoMapSet: true, it includes paths into nested object values
    for (const [path] of t.entries(m, { descendIntoMapSet: true })) {
      paths.push(path);
    }
  5. Control concurrency in async traversals

    main

    Both t.forEachAsync and t.mapAsync accept a concurrency option in the options object. By default, concurrency is 1 (sequential). Setting { concurrency: n } allows up to n sibling callbacks to run in parallel. Each branch receives an isolated path/parent snapshot, making this pattern suitable for I/O-bound tasks.

    import * as t from 'neotraverse';
    
    const obj = { a: 0, b: 0, c: 0 };
    
    await t.forEachAsync(
      obj,
      async (ctx) => {
        await fetch(`/api/item/${ctx.key}`);
      },
      { concurrency: 3 }
    );
    // up to three sibling fetches in flight at once
  6. Security: Prototype pollution and injection hardening

    main

    The functional API (neotraverse) is hardened against common security vulnerabilities when processing untrusted data:

    • No prototype pollution: set(path, value) refuses keys like __proto__, constructor, or prototype.
    • No prototype injection: clone() and map() preserve the real prototype of objects even when processing hostile JSON (e.g., {"__proto__":{"isAdmin":true}}).
    • Prototype preservation: Legitimate instanceof checks continue to work after a clone().
    • No prototype-chain disclosure: get() and has() only follow own properties.

    Note: These protections apply to the functional API. The neotraverse/legacy build is intentionally behavior-compatible with the original traverse and does not include these protections.

    import { clone } from 'neotraverse';
    
    const evil = JSON.parse('{"user":"bob","__proto__":{"isAdmin":true}}');
    clone(evil).isAdmin; // undefined
    ({}).isAdmin; // undefined, global prototype untouched
  7. Configure Map and Set traversal behavior

    main

    By default, Map and Set are treated as leaves during a walk (the walker sees the collection as a single node and does not descend into its contents).

    To visit the individual values within a Map or elements within a Set, you must provide the option { descendIntoMapSet: true } in your traversal configuration.

    Note that clone() always performs a deep copy of Map and Set entries regardless of this setting.

  8. Perform nested rewrites with `edit.replace(v, { descend: true })`

    main

    In neotraverse v2, edit.replace(v) is final by default. This design choice prevents infinite loops in self-feeding visitors. If you need to perform one-pass nested rewrites (for example, when dealing with $ref chains), you must explicitly pass the descend: true option.

    This opt-in mechanism ensures that the visitor only enters the newly replaced subtree when explicitly requested, maintaining a predictable traversal flow.

    // Example of opting into descending into a replaced node
    visit(root, (v, edit) => {
      if (isReference(v.value)) {
        edit.replace(resolvedValue, { descend: true });
      }
    });
  9. How `neotraverse/safe` works for deep or untrusted input

    main

    The neotraverse/safe entry point is an opt-in, stack-safe iterative core designed for input that is extremely deep (200,000+ levels), untrusted, or only partially consumed.

    Unlike the default recursive walker, /safe uses an iterative engine and supports:

    • Lazy iteration: Using visit, you can stop early (e.g., .take(5)) without materializing the entire tree.
    • Copy-on-write: The transform function shares untouched subtrees and returns the input by identity if no changes occur.

    Requirements: Node 22+ or evergreen browsers (uses native ES2025 iterator helpers).

    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('***')
    });
  10. Understand the `neotraverse` vs `neotraverse/safe` trade-off

    main

    Choosing between the default neotraverse and the neotraverse/safe package depends on your data shape and consumption pattern:

    Use default neotraverse when:

    • Performance is priority: It is significantly faster for standard traversals (e.g., forEach, map, clone).
    • Data is shallow: The default implementation is recursive. It will cause a stack overflow on very deep input structures.
    • Full materialization is required: If you need to transform the entire tree into a new structure, the default is more memory-efficient because it doesn't allocate a Visit object per node.

    Use neotraverse/safe when:

    • Stack safety is required: It uses an iterative engine, meaning it can handle arbitrarily deep trees (e.g., ASTs, file trees) without overflowing the stack.
    • Using early-exit/streaming: If you are performing a filtered scan and only need the first few results (e.g., find), /safe uses significantly less memory because its lazy iterators avoid materializing intermediate collections.
    • Handling untrusted JSON: It provides a layer of security against deeply nested malicious payloads.
  11. Understand neotraverse traversal limits and constraints

    main

    When using neotraverse, be aware of the following architectural constraints:

    • No prototype walking: Methods like get, has, and getPath only follow own properties. They do not traverse the prototype chain, making them safer for untrusted input.
    • No non-enumerable keys: The walker does not visit non-enumerable properties or getter-only keys unless custom logic is implemented.
    • Acyclic trees only: The diff and patch operations only support acyclic trees. Circular graphs are not supported.
    • Boxed primitives: Objects created via new String(), new Number(), etc., are classified as 'object' and treated as leaves. Use .valueOf() to unwrap them.
    • Cross-realm compatibility: While instanceof might fail in cross-realm environments, copy() uses tag checks to correctly handle Date and RegExp objects.