Skip to content

awnist/js-traverse-async

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

21 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

traverse-async

When you have a deeply nested object like a config tree, an API response, or document model, and need to do something at every level of it, writing the recursion yourself gets tedious. It gets harder still when the work at each node is asynchronous: a database lookup, a file read, a network call.

traverse-async handles the recursion for you. Give it any object or array and a callback, and it walks every node from the root down. The callback receives a context object describing the current node's position in the tree. Your callback can be async — traverse() returns a Promise that resolves when every node has been visited.

Installation

pnpm add traverse-async
# or
npm install traverse-async

Usage

import traverse from 'traverse-async'; // ESM
const traverse = require('traverse-async'); // CJS

Basic walk

const data = { a: { b: { c: 1 } }, d: 2 };

await traverse(data, ({ node, path }) => {
  console.log(path.join('.') || '(root)', '->', node);
});
// (root) -> { a: { b: { c: 1 } }, d: 2 }
// a      -> { b: { c: 1 } }
// d      -> 2
// a.b    -> { c: 1 }
// a.b.c  -> 1

Knowing when traversal is complete

traverse() returns a Promise that resolves with the original data once all nodes have been visited. Use await to know when it's done:

const result = await traverse(data, ({ node, key }) => {
  // visit every node
});

// execution continues here once all nodes have been visited
console.log(result === data); // true — resolves with the original value

Async callback

The callback can be async. traverse() awaits each callback's returned Promise before processing that node's children:

await traverse(data, async ({ node }) => {
  if (node?.url) {
    node.data = await fetch(node.url).then(r => r.json());
  }
});

Error handling

If the callback throws synchronously, or returns a Promise that rejects, the traversal is aborted and the traverse() Promise rejects with that error. No further nodes are visited.

try {
  await traverse(data, async ({ node }) => {
    const res = await fetch(node.url); // may throw
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    node.data = await res.json();
  });
} catch (err) {
  console.error('traversal failed:', err);
}

Replacing a node by return value

Returning a non-undefined value from the callback replaces that node in the tree. The traversal then recurses into the replacement (unless skip() is also called).

const obj = { a: 1, b: 2 };

await traverse(obj, ({ node, key }) => {
  if (key) return node * 10;
});

console.log(obj); // { a: 10, b: 20 }

This is shorthand for assigning to parent[key] directly. Both are equivalent:

// explicit
({ node, key, parent }) => { if (key) parent[key] = node * 10; }

// via return value
({ node, key })         => { if (key) return node * 10; }

Returning undefined (or not returning) leaves the node unchanged. This means replacing a node with undefined isn't possible via return value — use parent[key] = undefined directly for that case.

Return values for the root node are ignored since there is no parent to assign into.

For an immutable workflow, clone the data first and traverse the copy:

const result = await traverse(structuredClone(data), ({ node, key }) => {
  if (typeof node === 'string') return node.toUpperCase();
});
// data is unchanged, result is transformed

Skipping a subtree with skip()

Call skip() from the context to visit the current node but skip all of its descendants. The rest of the traversal carries on normally.

await traverse({ a: { b: { c: 1 } }, d: 2 }, ({ path, key, skip }) => {
  console.log(path.join('.') || '(root)');
  if (key === 'a') skip(); // skip everything inside a
});
// (root)
// a        ← visited, but b and c are never reached
// d

Use return skip() to exit the callback early at the same time:

await traverse(data, ({ key, skip }) => {
  if (key === '_private') return skip();
  // … handle everything else
});

Stopping traversal early with stop()

Call stop() to resolve the traverse() Promise immediately. No further nodes are visited.

let found = null;

await traverse(tree, ({ node, key, stop }) => {
  if (key === 'targetId' && node === 'abc123') {
    found = node;
    return stop(); // stop immediately, Promise resolves
  }
});

console.log(found); // 'abc123', or null if not found

stop() resolves the Promise (intentional early exit). To cancel from outside the traversal and have the Promise reject, use an AbortController instead — see Aborting from outside.

API

traverse(data, callback, [options])Promise<data>

Walks every node in data breadth-first, calling callback once per node.

Parameter Type Description
data any The root value to traverse. Objects and arrays are recursed into; primitives are visited as leaves.
callback(context) function Called for each node. Receives a context object. May be async. Call skip() to prune children, stop() to halt traversal, or do nothing to recurse.
options object (optional) { parallel, signal } — see below.

Returns a Promise that resolves with data once every node has been visited, or rejects if the callback throws or an abort signal fires.


Context

The single argument passed to callback is a plain object describing the current node.

node

The current value being visited.

await traverse({ a: 1 }, ({ node }) => {
  console.log(node); // { a: 1 }, then 1
});

key

The string key or array index under which this node lives in its parent. undefined for the root.

// data = { users: [{ name: 'alice' }] }
// visiting 'alice':  key === 'name'
// visiting the array: key === 'users'
// visiting the root:  key === undefined

parent

The object or array that contains this node. undefined for the root.

// Delete the current node from its parent
await traverse(obj, ({ key, parent }) => {
  if (key === 'secret') delete parent[key];
});

path

Array of keys from the root down to the current node. Empty array [] for the root.

// data = { a: { b: { c: 1 } } }
// visiting c:    path === ['a', 'b', 'c']
// visiting b:    path === ['a', 'b']
// visiting root: path === []

await traverse(data, ({ node, path }) => {
  console.log(path.join('.')); // 'a', 'a.b', 'a.b.c', …
});

isRoot

true only for the root node. Useful for skipping it in callbacks that only care about children.

await traverse(data, ({ node, isRoot }) => {
  if (isRoot) return; // skip the root itself
  console.log(node);
});

skip

Call skip() to visit this node but skip all of its children. The traversal continues with siblings and the rest of the tree.

await traverse(data, ({ key, skip }) => {
  if (key === '_private') return skip(); // prune this subtree
});

Calling skip() does not stop the callback — code after the call still runs. Use return skip() to exit early.

stop

Call stop() to halt traversal immediately. The traverse() Promise resolves with the original data.

let found = null;
await traverse(data, ({ node, stop }) => {
  if (node?.id === target) { found = node; return stop(); }
});

Unlike an AbortController abort, stop() resolves the Promise rather than rejecting it.

push

Injects an additional node into the traversal queue. The injected node and its children are traversed normally.

await traverse(data, ({ node, path, push }) => {
  if (node?.type === 'ref') {
    push({ node: resolveRef(node), path: [...path, 'resolved'], parent: node });
  }
});

Options

Pass an options object as the third argument to traverse().

parallel

Number of nodes processed concurrently. Defaults to 1 (serial).

await traverse(assetTree, async ({ node }) => {
  if (node?.type === 'image') {
    node.data = await fetchImage(node.src);
  }
}, { parallel: 4 });

Each traverse() call uses its own concurrency setting — there is no global state.

Parallel caveats:

  • **context.node is a snapshot.** Each node's value is captured when it is added to the queue. If a concurrent sibling mutates the same parent key, context.node will be stale — use parent[key] to read the live value.
  • Last write wins. With parallel > 1, two sibling callbacks may both produce a return value or mutate the same key. The one whose finish() runs last sets the final value. Avoid cross-sibling mutations when using parallelism.
  • Children come from the replacement. If a callback returns a new object, the traversal recurses into that replacement — not the original node. Siblings running concurrently are not affected since their children haven't been queued yet.
  • **stop() drops in-flight return values.** Once traversal is halted, any sibling callbacks still running to completion will have their return values silently discarded.

signal

An AbortSignal for cancelling the traversal from outside. The traverse() Promise rejects with an AbortError when the signal fires.

const controller = new AbortController();

setTimeout(() => controller.abort(), 5000); // cancel after 5s

try {
  await traverse(data, async ({ node }) => { /* … */ }, { signal: controller.signal });
} catch (err) {
  if (err.name === 'AbortError') console.log('traversal was cancelled');
  else throw err;
}

AbortSignal.timeout(ms) works too:

await traverse(data, handler, { signal: AbortSignal.timeout(5000) });

Aborting from outside

Use an AbortController when you need to cancel traversal from code outside the callback (a timeout, a user action, a race with another Promise). The traverse() Promise rejects with an AbortError.

const controller = new AbortController();

const p = traverse(data, handler, { signal: controller.signal });

// somewhere else:
controller.abort();

await p; // throws AbortError

Traversal behaviour

  • Order: breadth-first (BFS). All children of the current depth are queued before descending.
  • Objects and arrays are recursed into. Their keys/indices are visited as child nodes.
  • Primitives (string, number, boolean, null, undefined) are visited as leaves with no children.
  • Promises (any object with a .then method) are visited as leaves — their internals are not traversed.
  • Only own enumerable string keys are visited. Inherited, non-enumerable, and symbol-keyed properties are skipped.
  • Sparse array holes are skipped ([1,,3] visits indices 0 and 2 only).
  • Class instances are recursed into (own enumerable properties only).
  • Date, RegExp, Map, Set are treated as leaves and not recursed into.
  • Circular references are detected via a per-traversal WeakSet. A node whose object reference has already been recursed into is visited (the callback fires) but its children are not queued again, preventing infinite loops.
  • Shared references (same object reachable via multiple keys) follow the same rule: the callback fires once per key, but children are only queued the first time the object is encountered.
  • Mutation: assigning to parent[key] before the callback returns causes the traversal to recurse into the new value instead of the original.
  • Concurrency: default is serial (parallel: 1). Pass { parallel: N } to traverse() to process up to N nodes at once.

Examples

Skip arrays

Call skip() when the node is an array to prevent the traversal from descending into it. Useful when arrays contain homogeneous data you want to treat as a unit rather than walk element by element.

await traverse(data, ({ node, skip }) => {
  if (Array.isArray(node)) return skip();
  // only object nodes and primitives reach here
});

To process the array itself but skip its contents conditionally:

await traverse(data, ({ node, key, skip }) => {
  if (Array.isArray(node) && node.length > 100) {
    console.log(`skipping large array at ${key} (${node.length} items)`);
    return skip();
  }
});

Act on keys matching a pattern

Check key against a string, set, or regex to target only the nodes you care about and ignore the rest.

await traverse(config, ({ key, node, parent }) => {
  if (/_url$/i.test(key)) {
    parent[key] = node.replace('http://', 'https://');
  }
});

Redact sensitive fields before logging

Strip passwords, tokens, and secrets from a payload at any nesting depth before it reaches a logger or external service.

const SENSITIVE = new Set(['password', 'token', 'secret', 'apiKey']);

await traverse(payload, ({ key, parent, skip }) => {
  if (SENSITIVE.has(key)) {
    parent[key] = '[redacted]';
    return skip(); // no need to recurse into the redacted value
  }
});

logger.info(payload);

Interpolate environment variables in a config object

Walk a loaded config and replace ${VAR} placeholders with real values before the app starts.

const ENV_PLACEHOLDER = /\$\{([^}]+)\}/g;

await traverse(config, ({ node }) => {
  if (typeof node === 'string') {
    return node.replace(ENV_PLACEHOLDER, (_, name) => process.env[name] ?? '');
  }
});

startApp(config);

Replace a node and traverse the replacement

Assign a new value to parent[key] before the callback returns. The traversal will recurse into the replacement, not the original.

const templates = {
  greeting:  { text: 'Hello!', style: 'bold' },
  separator: { text: '---',    style: 'plain' },
};

await traverse(data, ({ node, key, parent }) => {
  if (node?.type === 'template') {
    parent[key] = templates[node.name] ?? { text: '', style: 'plain' };
    // traversal will recurse into the replacement
  }
});

This works for any replacement — objects, arrays, or primitives. If the replacement is a primitive or has no children, the traversal simply advances without recursing.

Read files into a tree

Load file contents into a nested structure in one pass.

import { readFile } from 'fs/promises';
import traverse from 'traverse-async';

const tree = {
  config: { path: './config.json' },
  i18n: {
    en: { path: './locales/en.json' },
    fr: { path: './locales/fr.json' },
  },
};

await traverse(tree, async ({ node }) => {
  if (node?.path) {
    node.content = JSON.parse(await readFile(node.path, 'utf8'));
  }
}, { parallel: 4 });

// tree.config.content, tree.i18n.en.content, etc. are now populated

Hydrate database references

An API response contains { $ref: 'users/42' } nodes scattered at arbitrary depths. Replace each one with the real record in place.

await traverse(document, async ({ node }) => {
  if (node?.$ref) {
    return await db.get(node.$ref); // replaces the $ref node; traversal recurses into the result
  }
});

render(document);

Check all URLs in a document

Walk any structure that may contain URL strings at arbitrary depth and verify each one responds successfully.

const broken = [];

await traverse(document, async ({ node, path }) => {
  if (typeof node === 'string' && node.startsWith('https://')) {
    const res = await fetch(node, { method: 'HEAD' });
    if (!res.ok) broken.push({ url: node, status: res.status, path: path.join('.') });
  }
}, { parallel: 5 });

console.log('broken links:', broken);

Build a flat map of all leaf paths

Reduce a nested object to a plain { 'a.b.c': value } map — useful for diffing, serialising form state, or generating dot-notation keys.

const flat = {};

await traverse({ a: { b: 1 }, c: [2, 3] }, ({ node, path }) => {
  if (typeof node !== 'object' || node === null) {
    flat[path.join('.')] = node;
  }
});

console.log(flat);
// { 'a.b': 1, 'c.0': 2, 'c.1': 3 }

Resolve nested promises

Walk a structure that may contain promises at arbitrary locations and await each one in place.

await traverse(data, async ({ node }) => {
  if (node != null && typeof node.then === 'function') {
    return await node; // replaces the promise with its resolved value; traversal recurses into it
  }
});

console.log('all promises resolved', data);

Delete nodes

Remove keys from an object while traversing — for example, stripping all null values.

await traverse(obj, ({ node, key, parent }) => {
  if (node === null) delete parent[key]; // return value can't express deletion, so mutate directly
});

Cancel a long-running traversal

Use AbortController to cancel from outside the callback — for example, on a timeout or user action.

const controller = new AbortController();

// Cancel if the user navigates away
window.addEventListener('beforeunload', () => controller.abort());

try {
  await traverse(largeTree, async ({ node }) => {
    await processNode(node);
  }, { signal: controller.signal });
} catch (err) {
  if (err.name === 'AbortError') console.log('cancelled');
  else throw err;
}

Non-destructive traversal

traverse-async operates directly on the object you pass in. When you need to preserve the original, clone it first and traverse the copy:

import traverse from 'traverse-async';
import { klona } from 'klona';

const result = klona(original);

await traverse(result, ({ node, key, parent }) => {
  if (typeof node === 'string') {
    parent[key] = node.toUpperCase();
  }
});

// original is untouched, result is transformed
console.log(original); // { name: 'alice', address: { city: 'london' } }
console.log(result);   // { name: 'ALICE', address: { city: 'LONDON' } }

klona handles plain objects, arrays, Date, RegExp, Map, Set, and typed arrays. For plain JSON-like data, the built-in structuredClone works without any dependency.


Breaking changes in 1.0.0

The API has been redesigned around Promises. If you are upgrading from 0.x, here is what changed:

Callback signature. The callback no longer receives (node, next, stop) with this as context. It now receives a single context object: ({ node, key, parent, path, isRoot, skip, stop, push }).

No next(). You no longer call next() to advance the traversal. Just return from the callback (or return a Promise). Traversal proceeds automatically when the callback returns or its Promise resolves.

stop() vs skip(). In 0.x, calling stop() skipped the current node's children. That behaviour is now skip(). The new stop() halts the entire traversal immediately and resolves the Promise.

Return value replaces the node. Returning a non-undefined value from the callback now replaces that node in the tree. Previously the return value was ignored.

traverse() returns a Promise. In 0.x it returned the internal queue object. The done(err, data) third argument is gone — use await or .then() instead.

config() is gone. Pass { parallel: N } as the third argument to traverse() instead.

break() is gone. Use stop() to resolve early, or an AbortController to reject from outside the callback.

License

Unlicense — public domain.

About

Javascript module to traverse objects asynchronously

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors