zoo.do: an 11.2 KB proxy that treats many objects as one

zoo.do is a single-file 11.2 KB proxy library that wraps any number of objects so one property read gathers every value and one assignment writes to them all.

Share
A murmuration of starlings forming one arrow over a dawn ocean, with a tiny figure raising an arm on a sea cliff

Somewhere in your codebase there is a loop that disables some buttons, and next to it a second loop that remembers what was disabled before, because restoring is never as simple as enabling. zoo.do exists for exactly that code. It wraps any number of objects (DOM elements, plain objects, whatever you have) behind one proxy, so that one property read gathers every value and one assignment writes them all. The snapshot problem dissolves: a read IS the snapshot, and assigning it back restores every target to its own value.

Every example in this article runs live on this page, against the same frozen copy of the library inlined at the top of this post. The dark panes are real output, produced by the exact code shown above them. (Feed readers strip scripts; the prose states everything the demos show.)

const cfg = { id: 'cfg', disabled: false };          // a plain object, riding along
const X = $(document.querySelectorAll('#zd1 .zd-stage :is(button,input)'), cfg);
const before = X.disabled;            // identity-keyed snapshot of five mixed states
X.disabled = true;                    // one assignment, five targets
console.log('frozen:  ', ...X.disabled);
await new Promise(r => setTimeout(r, 600));
X.disabled = before;                  // the same assignment, run in reverse
console.log('restored:', ...X.disabled);
Watch the stage while it runs: the Retry button was already disabled, and after the restore it still is. The snapshot is keyed by the objects themselves, not by position.

What zoo.do is

One file, two globals. The library defines $ and $$, weighs 11.2 KB as served (about 3.6 KB gzipped) across 189 lines, and lives at zoo.do/.js, where it is published with an open CORS header straight from the playground page it was built in. There is no package, no build step, and no framework underneath: it is a JavaScript Proxy with strong opinions about collections. A frozen copy is inlined as the first script card of this very post, byte for byte identical to the served file after newline normalization, and it is what every demo here runs against; View Source shows you the whole thing.

The name is the mental model. A zoo is many different animals under one management: you do not ask the zebra and the tortoise to share an API before you can feed them. $(...) accepts DOM elements next to plain objects next to class instances, and treats them identically, because property access is the one interface every JavaScript object already has.

Reads gather, writes scatter

Everything in zoo.do follows from one symmetry. A write to the proxy fans out: X.disabled = true performs that assignment on every target, whether it is a button or a config object. A read from the proxy fans in: X.value reads every target and hands you the results as a chainable map, keyed by the targets themselves.

One assignment broadcast to every target X.disabled = true #save.disabled = true button #retry.disabled = true button #cancel.disabled = true button cfg.disabled = true plain object one assignment every target, whatever it is
The dashed lane is the point: cfg is a plain object riding in the same collection as three buttons; zoo.do never checks what a target is.

The gathered side has three doors out of the proxy world. Spreading a read (...X.value) yields [target, value] pairs. The .$ property hands you the underlying real collection: a $.Set of targets on the collection itself, a $.Map of entries on any property read. And coercion votes: +X.width or `${X.fw}` produces the single shared value when every target agrees; on disagreement a template literal gives an empty string and a numeric coercion gives $.NO_CONSENSUS, a recognizable sentinel with its own $.isNoConsensus test. Agreement is checked by identity (SameValueZero, so NaN agrees with NaN), and targets whose value is undefined or a captured error abstain rather than veto.

A property read gathers a map keyed by the targets #aw: 3 #bw: 4 #cw: 3 X.w Map#a -> 3#b -> 4#c -> 3keyed by the targets +X.w with values {3, 3, 3} -> 3 +X.w with values {3, 4, 3} -> NaN collapses: disagrees:
The gathered map preserves target order, and its keys are the live objects themselves, so iterating a read hands you each owner next to its value.

One habit to build early: a read is always a truthy proxy object, even when every gathered value is false, so if (X.disabled) tests the wrong thing. Coerce on purpose (+X.disabled), or look at the real values through .$. Iteration, on the other hand, simply works: spreading the collection ([...X]) yields the targets, and spreading any property read yields the [target, value] pairs.

Assign a function, transform every target

The right-hand side of an assignment is a little language. A plain value broadcasts. A function wrapped in $() becomes a per-target transform: zoo.do reads every target first, then runs your function once per target with (value, target, allPreReadValues), then writes every result back. Reads, transforms, and writes happen in three strict phases, so a transform can safely look at the whole picture through its third argument.

const reviews = [
  { id: 'r1', score: 85 }, { id: 'r2', score: 92 }, { id: 'r3', score: 78 },
];
const all = $(reviews);
all.score = $((v, target) => target.id === 'r2' ? v + 10 : v + 5);
console.log(...all.score);
The second argument is the target itself, so one transform can branch per object; the third (unused here) is the map of every pre-read value.

Snapshot now, restore later

The third right-hand shape is the quiet star of this library: assigning a Map. Each collection member that appears as a key in the Map receives its own value (call it unicast, where a plain value is broadcast); members missing from the Map keep what they have; entries whose value is an error are skipped. Now look back at the first demo on this page. const before = X.disabled read a map keyed by the five targets; X.disabled = before routed each saved value back to its own object. That is undo, drain-and-rollback, and optimistic UI in one move, and because the keys are the objects themselves rather than array positions, the restore survives reordering and refiltering.

Two refinements round it out. Since reads allocate a fresh map each time, a held read never goes stale by accident: it is a snapshot by construction. And when you hold one in a const, the pseudo-property .$V writes through it anyway (a value, a transform, or another Map), then refreshes the held map from the targets.

Errors are values

Every per-target operation in zoo.do runs inside its own try/catch. A getter that throws, a missing intermediate object, a method that explodes for one target: the failure is caught, stored as that target's value, and the operation continues for everyone else. Once an error enters a lane it rides every later chain step unchanged, by reference, so the same Error object you fish out at the end is the one minted where things went wrong.

Errors become values and ride the chain X.a .b .c .d #ok {...} {...} {...} 42 #broken (no .b) {...} undefined Error Error nothing throws: the read through undefined captures one TypeError, which then rides every later step unchanged
The captured TypeError is the same object at every later step, so identity checks can tell you exactly where a lane went wrong.

This is why chains through optional structure need no guards at all. Where vanilla JavaScript reaches for ?. and still throws on a method call two steps later, a zoo.do chain simply keeps going, and you sort survivors from casualties at the end with $.isErr.

const ok     = { id: 'ok', user: { plan: { price: 9 } } };
const broken = { id: 'broken' };                 // has no .user at all
const price = $(ok, broken).user.plan.price;     // nothing throws, ever
console.log(...price);
console.log('usable:', price.$.filter(v => !$.isErr(v)));
The error text comes from the JavaScript engine, not the library, so the wording varies by browser and may name the library internals instead of the property you asked for.

Live collections re-ask the question

$() answers "what was there when I asked". $$() answers "what is there right now", every single time. A live collection runs zero queries when you create it; each read, write, or .$ access re-runs the selector or generator from scratch. Membership becomes a predicate rather than a list you must maintain.

Static collections freeze membership; live ones re-ask access 1 access 2 access 3 $(...) static $$(...) live #a #b #c #a #b #c #a #b #c #a #b #c #a #b #c #d #a #d +#d joins #b and #c leave same expression, different question: what was there, or what is there now
$$ runs the query again on every single access, reads and writes alike; nothing is cached, which is the entire point and the entire cost.

Selectors are the obvious use, but the generator form is the general one: any rule you can express as "yield the things that qualify" becomes a permanent, always-current handle.

const tasks = new Map([
  ['a', { id: 'a', status: 'open' }],
  ['b', { id: 'b', status: 'done' }],
  ['c', { id: 'c', status: 'open' }],
]);
const open = $$(function* () { for (const t of tasks.values()) if (t.status === 'open') yield t; });
open.status = 'in-progress';          // sweeps a and c
tasks.get('b').status = 'open';       // b reopens after that sweep
open.status = $(s => s + '!');        // same handle, next sweep: only b matches now
console.log(...$(tasks).status);
Objects join the moment the generator would yield them: the two sweeps in this card hit different task sets through one unchanged handle.

Async that keeps its keys

Call an async method on a collection and you get a map of promises, keyed by the targets. The usual next step, Promise.all, would flatten that into an anonymous array and reject wholesale on the first failure. zoo.do extends the built-in instead: Promise.all.map awaits a Map of promises and returns the same keys with settled values, capturing rejections as values by default (catch: false restores fail-fast, and a custom catch(reason, key, map) can substitute fallbacks per key).

Key-preserving async resolution Map in Map out users -> Promise users [...] orders -> Promise orders 402 rows audit -> Promise audit Error("denied") Promise.all.map(M) same keys on both sides; the rejection arrives as a value, not a throw
The sibling Promise.allSettled.map returns a status record per key instead, and both extensions keep input order no matter which promise settles first.

No index bookkeeping, no zip step, no lost association between who you asked and what they said.

const us = { name: 'us-east-1', ping: () => new Promise(r => setTimeout(r, 30, 12)) }; // settles last
const eu = { name: 'eu-west-1', ping: async () => 31 };
const ap = { name: 'ap-south-1', ping: async () => { throw new Error('socket timeout'); } };

console.log('ping ms:', await Promise.all.map($(us, eu, ap).ping()));
Promise.all.map accepts the proxy map directly and returns a real $.Map in target order, no matter which promise settles first; the rejected ping lands as an Error value instead of failing the await.

Nine enclosures

The mechanisms above compose, and the compositions are where the library earns its keep. What follows is a tour through nine small, real scenarios from different corners of programming. Every one was run before it was written about, and each demonstrates a distinct combination of the mechanisms above.

Operations

Start with the strongest composition in the whole post.

const auth = { name: 'auth', lastGood: 'v1', healthz: async () => 'v2' };
const cart = { name: 'cart', lastGood: 'v1', healthz: async () => { throw new Error('ECONNREFUSED'); } };
const mail = { name: 'mail', lastGood: 'v1', healthz: async () => 'v2' };

const fleet = $(auth, cart, mail);
fleet.lastGood = await Promise.all.map(fleet.healthz());
console.log(...fleet.lastGood);
The round trip only works because Promise.all.map hands back a Map keyed by the very objects the proxy holds, so the assignment can route every result home.

That card is the whole library in one line: a fanned-out call, a key-preserving await, and a unicast write-back composing so that the crashed service keeps its last known good value without a single conditional. The next two scenarios lean on the snapshot Map.

const lb1 = { id: 'lb-fra-1', weight: 100 };
const lb2 = { id: 'lb-fra-2', weight: 80 };
const lb3 = { id: 'lb-fra-3', weight: 20 };   // already half-drained
const pool = $(lb1, lb2, lb3);

const saved = pool.weight.$;                  // snapshot: Map of device to weight
pool.weight = 0;                              // drain everything for the risky change
console.log('drained:', ...pool.weight);
pool.weight = saved;                          // rollback: each device gets its own value back
console.log('restored:', ...pool.weight);
The heterogeneous weights are the proof: 100, 80, and 20 all come back to the right machine, because the snapshot Map is keyed by the device objects.

State and simulation

The same shape powers an undo stack: push a read, mutate, pop into an assignment.

const bands = [{ id: 'low', gain: 2 }, { id: 'mid', gain: 5 }, { id: 'high', gain: 9 }];
const eq = $(bands), undo = [];
undo.push(eq.gain.$); eq.gain = 0;             // snapshot, then broadcast: flat preset
undo.push(eq.gain.$); eq.gain = $(g => g + 3); // snapshot, then transform: up 3 dB
console.log('now   :', ...eq.gain);
eq.gain = undo.pop();
console.log('undo 1:', ...eq.gain);
eq.gain = undo.pop();
console.log('undo 2:', ...eq.gain);
Push, mutate, and pop are one expression each; the stack itself holds only snapshot Maps, while the writes that happen around it span broadcast, transform, and unicast.

Games and simulations get the most out of live membership, because their populations change every tick.

const units = [{ id: 'knight', hp: 80 }, { id: 'archer', hp: 12 }, { id: 'mage', hp: 60 }];
const critical = $$(function* () { for (const u of units) if (u.hp < 20) yield u; });

critical.hp = 25;  // triage tick: archer is the only critical unit right now
units[0].hp = 8; units.push({ id: 'recruit', hp: 5 });  // knight mauled, recruit spawns hurt
critical.hp = 25;  // same line, next tick: heals the knight and the recruit instead

console.log(...$(units).hp);
console.log('still critical:', critical.$.size);
The recruit was pushed into the plain array between the two heal lines and still got healed: nothing subscribed it, the generator simply yielded it on the next sweep.

Configuration work is where the deletion broadcast and the in operator do their best work.

const mk = id => ({ id, flags: { betaCheckout: true, darkMode: true } });
const dev = mk('dev'), staging = mk('staging'), prod = mk('prod');
Object.freeze(prod.flags);                        // prod is in a change freeze
const envs = $(dev, staging, prod);
delete envs.flags.betaCheckout;                   // retire the flag fleet-wide
console.log('anyone still pinning it?', 'betaCheckout' in envs.flags);
console.log(...envs.flags.betaCheckout);
The in operator doubles as the retirement audit: it answers true if any environment still carries the flag, which is exactly the question a migration script asks.

Data and the DOM

Data wrangling benefits from the error discipline: a batch where one bad record cannot sink the rest.

const feed = [
  '{"id":"A1","qty":2}',
  '{"id":"B7","qty":',
  '{"id":"C3","qty":5}',
];
console.log(new $.Set(feed).map(JSON.parse).$);
$.Set.map captures a throwing callback per element; its sibling $.Map.map deliberately does not, and lets the throw propagate.

And zoo.do's Map.invert extension turns a property read into a duplicate report in one expression.

const users = [
  { id: 1, email: 'ana@example.com' },
  { id: 2, email: 'bo@example.com'  },
  { id: 3, email: 'ana@example.com' },
  { id: 4, email: 'cy@example.com'  },
];
const dupes = Map.invert($(users).email).filter(Array.isArray);
console.log(...dupes);
Map.invert consumes the proxy map directly and promotes colliding keys into an array at the first duplicate, which makes filter(Array.isArray) the whole dedup report.

Back in the DOM, the browser itself can be the predicate: :invalid is a live rule the engine maintains for free.

const invalid = $$('#zd14 input:invalid');         // live: re-queries on every use
invalid.ariaInvalid = 'true';
console.log('invalid now:', ...invalid);
document.querySelector('#zd14 input').value = 'Ada';   // the user types a name
console.log('after typing:', ...invalid);
console.log('stale flag stays:', document.querySelector('#zd14 input').ariaInvalid);
The :invalid selector is the browser doing the bookkeeping; zoo.do just re-asks it on every access. Note what does not happen: the stale aria flag on the fixed field stays, because a live collection is membership, not a subscription.

Finally, the consensus vote turns fleet drift into a one-line question.

const s1 = { id: 'sensor-a', fw: '4.2.0' };
const s2 = { id: 'sensor-b', fw: '4.1.9' };
const s3 = { id: 'sensor-c', fw: '4.2.0' };
const s4 = { id: 'sensor-d' };                // never reported a version
const fleet = $(s1, s2, s3, s4);

console.log(`consensus: [${fleet.fw}]`);      // empty: the fleet disagrees
console.log('lagging:', ...[...fleet.fw].filter(([, v]) => v !== '4.2.0'));
s2.fw = '4.2.0';
console.log(`consensus: [${fleet.fw}]`);      // silent sensor-d does not block the vote
One read does triple duty here: coerced for consensus, spread for the stragglers, and the unanimity rule ignores undefined and Error values when it counts the votes.

The dollar sign, then and now

The $ carries history, and zoo.do wears it on purpose. jQuery's dollar made "many elements, one statement" the most copied idea in front-end history: $('.item').hide() needed no loop, and an entire generation learned the web on that grammar. zoo.do is a deliberate generalization of that idea past the DOM: many anything, one statement.

Credit where it is due, and it is due in a specific place: jQuery's setters take functions too. $('input').val((i, v) => v.toUpperCase()) transforms each field individually, and it has worked since long before this library existed. For that one task the two are exactly as short as each other:

jQuery('#f input').val((i, v) => v.toUpperCase());   // jQuery, per element
$('#f input').value = $(v => v.toUpperCase());       // zoo.do, per target

The differences start one step away from that happy path, and we verified each of these against jQuery 3.7.1 and Lodash 4.17.21 before writing them down. First: jQuery getters answer for one element. .val(), .prop(), .css() all return the first match's value, so the getter gives you no whole-set read, and a keyed snapshot is your own bookkeeping. The idiomatic jQuery snapshot is index-keyed (.map((i, el) => el.disabled).get() restored via .prop('disabled', i => saved[i])), and it works, but it breaks the moment the set is reordered or refiltered between save and restore; zoo.do's snapshot is keyed by the elements themselves. Second: error isolation. Throw inside a jQuery .each callback and the iteration stops dead; the remaining elements are never visited. In zoo.do the exception becomes that one target's value and the rest of the herd keeps moving. Third: liveness. A jQuery set is frozen at query time, so elements added later never join it, and even the .live() name for delegated events went in 1.9 (delegation survives as .on() with a selector). zoo.do makes liveness itself a constructor.

Lodash plays a different position, so the honest comparison is narrower. _.set(obj, 'a.b.c', 1) writes one nested path on one object beautifully, and it will even create the missing intermediate objects, which zoo.do deliberately will not (a zoo.do nested write silently skips targets that lack the path). But fanning that write across ten objects is still your loop to write, reads do not come back keyed, and lodash ships no Promise combinators at all: the keyed-async problem is simply out of its scope.

targets.forEach(t => _.set(t, 'theme.accent', accent));   // lodash: loop is yours
$(targets).theme.accent = accent;                          // zoo.do: write is the loop

Size, measured from the official CDNs on the day of writing and gzipped with identical flags: jQuery 3.7.1 is 87.5 KB minified (30.2 KB gzipped), its slim build 70.3 KB (23.9 KB), lodash 4.17.21 is 73.0 KB minified (25.7 KB), and zoo.do is 11.2 KB of unminified, commented source (3.6 KB gzipped). That gap measures scope, not skill: jQuery normalizes events, effects, and a decade of browser disagreements; lodash is a general toolkit of three hundred functions. Neither ever promised transparent property broadcast over arbitrary objects. That is the one job zoo.do does, and at roughly an eighth of jQuery's weight it does it with room to spare.

Honest edges

A library this magical owes you a map of where the magic stops. The first draft of this article listed ten edges here. Writing them down turned six of them from edges into bug reports, so we fixed the library instead: the fixes shipped to zoo.do/.js, and the copy this page runs was re-frozen from the result. Spreading a collection now yields its targets instead of a silent nothing. Object.prototype.toString no longer crashes on the proxies (a genuine invariant bug our probing surfaced). null and undefined are both dropped at the door, as arguments and inside iterables. $(generatorFn) now takes a static snapshot of the yields, the natural sibling of live $$(generatorFn). A broadcast delete never throws again, in any strictness: the per-target truth lands in $.lastDelete and failures report through $.onWriteError. And compound operators grew real semantics, which deserves its own paragraph.

X.tries-- used to be this library's sharpest edge: the operator coerces the read to a single primitive before the proxy can fan anything out, so disagreeing targets collapsed to NaN and that NaN was broadcast back over everyone, silently. The fix is the sentinel from the coercion rule above. When a numeric coercion finds disagreement it returns $.NO_CONSENSUS and arms a one-shot record of every target's old value; if the next write to that property in the same synchronous run lands within one step of the sentinel, the library recovers the per-target delta. So X.tries--, X.tries++, and X.tries -= 1 now genuinely work on mixed values. Anything whose operand cannot be reconstructed (+=, *=, larger steps) is blocked instead: state untouched, $.onWriteError fired per target.

const a = { id: 'a', tries: 3 }, b = { id: 'b', tries: 5 };
const X = $(a, b);
X.tries--;                        // mixed values: the sentinel recovers the step
console.log('after X.tries--:  ', ...X.tries);
const prev = $.onWriteError;
$.onWriteError = (t, p, v, e) => console.log(t, e);
X.tries *= 2;                     // operand unrecoverable: blocked, loudly
console.log('after X.tries *= 2:', ...X.tries);
$.onWriteError = prev;
A write only counts as compound when it lands in the same synchronous run as the coercion that armed it (the record disarms at the next microtask); an ordinary numeric write right after a drift check broadcasts normally.

What remains is what stays by choice. Reads are always truthy, as covered above; a proxy is an object and objects cannot be falsy, so that one is physics, not policy. Arguments flatten: $(users) proxies the users inside the array, and operating on arrays themselves takes one more pair of brackets, $([a, b]). The unanimity vote compares by identity, so two deep-equal objects do not count as agreement, by design.

Globals deserve their own disclosure. zoo.do assigns window.$ and window.$$ unconditionally, with no noConflict escape: load order decides, and it will shadow the DevTools console helpers of the same names. It also extends built-ins, guardedly (Map.invert, Promise.all.map, Promise.allSettled.map, each entry point ??=-guarded so existing definitions win), and one piece of its configuration is mutable global state: assigning Promise.all.map.catch = false flips the default for every later call on the page. This very article defines $ on the page you are reading; we checked that nothing else here wanted the name first.

The API on one page

Everything above, in reference form. The $.document hook in the escape-hatch table is how this article's examples were verified outside a browser before they ever ran in one.

Entry points

CallReturns
$(a, b, ...)A static collection proxy over the targets. Iterable arguments are flattened into their elements, so $(arrayOfUsers) proxies the users; wrap arrays in another iterable ($([a, b])) to proxy the arrays themselves. Nullish values are dropped, as arguments and inside iterables.
$('selector')A static snapshot of querySelectorAll taken at call time (exactly one query, never repeated).
$(fn)A $.Function transform for use on the right side of an assignment. A single sync generator function does not land here: $(function* () {...}) takes a one-time static snapshot of the yields, the static sibling of $$(genFn).
$$('selector')A live collection: zero queries at creation, one fresh querySelectorAll per operation.
$$(function* () {...})A live collection whose membership is the generator's yield set, re-run on every operation.

Operations on a collection X (and on every chained property proxy)

OperationSemantics
X.propReads every target; returns a chainable map proxy keyed by the targets. Reads that throw store the Error as that target's value; chains never throw.
...X.propSpreading a property read yields [target, value] pairs. Spreading the collection itself ([...X]) yields the targets; live collections re-query for each iteration. Both proxies answer Object.prototype.toString as [object $.Set] / [object $.Map].
X.prop = valueBroadcast write to every target (failed writes call $.onWriteError).
X.prop = $(fn)Per-target transform: reads all, then fn(value, target, mapOfPreReadValues) for all, then writes all.
X.prop = someMapUnicast: each collection member that appears as a key receives its own value; members not in the map keep their value; entries whose value is an Error are skipped. (One level deeper, X.a.b = map writes to the map's keys directly.)
X.method(args)Calls per target with this bound to the value's owner; non-function values pass through unchanged; results come back as a map proxy.
'p' in XTrue if any target has the property (own or inherited); Error and null values are skipped on map proxies.
delete X.propDeletes from every target and never throws, whatever the strictness. Per-target outcomes land in $.lastDelete (a $.Map of target to boolean); failed deletions fire $.onWriteError; poison keys are refused with all-false outcomes.
+X.prop, `${X.prop}`Unanimity coercion: the shared value if every target agrees (identity-strict; undefined and Error values do not vote). On disagreement a template literal gives '' and a number-hint coercion gives the $.NO_CONSENSUS sentinel, which also arms compound-operator recovery: X.p--, X.p++, and X.p -= 1 apply per target with each target's own previous value, while unrecoverable forms (+=, *=, larger steps) are blocked through $.onWriteError with state untouched.

Escape hatches and hooks

HookMeaning
X.$On a collection: a $.Set of the targets (on static collections its add/delete/clear write through to the backing snapshot). On a property proxy: the underlying $.Map (the same object every time).
X.prop.$V = vWrites through a const-held property map: accepts a value, a $(fn), or a Map, then refreshes the held map from the targets.
$.onWriteError(target, prop, value, error) => called for every failed write instead of throwing.
$.lastDeleteThe per-target outcome map of the most recent broadcast delete.
$.NO_CONSENSUS / $.isNoConsensus(v)The no-consensus sentinel a number-hint coercion returns on disagreement, and its test.
$.documentSwap in a fake { querySelectorAll } for tests; selector collections resolve against it, no browser needed.
$.read / $.trySet / $.isErr ...The small helper kit the library itself is built from, exposed on $.

Classes and Promise extensions

APIMeaning
$.MapMap plus map(fn), filter(fn) (keys preserved, order preserved) and invert().
$.SetSet plus map(fn), which returns a proxy map keyed by the elements and captures per-element throws as Error values.
Map.invert(m, { vK })Swaps keys and values; colliding keys promote the value to an array at the first duplicate's position. Returns the input's constructor; works directly on proxy maps.
Promise.all.map(m, { catch, [Promise.all.map.KV]: kv })Resolves a Map<key, Promise> (or a proxy map) preserving keys and order. Rejections become values by default; catch: false restores fail-fast; a custom catch(reason, key, map) substitutes a value.
Promise.allSettled.map(m)Same shape with { status, value | reason } records per key.

Scope: current evergreen browsers and runtimes with Proxy (which cannot be polyfilled). The whole library is the script card at the top of this article; the canonical copy is zoo.do/.js.

Closing time at the zoo

Proxies have been in every browser for nearly a decade, and mostly we used them for frameworks' internals: change detection, reactivity, dev-mode warnings. zoo.do uses them for something plainer and stranger: making "do this to all of them" a sentence of JavaScript rather than a paragraph. One read gathers, one write scatters, errors ride along, and membership can be a question you keep asking.

The gates stay open: the whole library is the script card near the top of this page, and the canonical copy lives at zoo.do/.js. Bring any objects you like; the enclosure fits them all.