Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Map.prototype.getAndDelete & WeakMap.prototype.getAndDelete

A proposal to add a getAndDelete(key) method to Map and WeakMap that removes the entry for key and returns its value (or undefined if the key was absent) in a single operation.

  • Stage: 1
  • Champion: Devin Rousso
  • Authors: Devin Rousso

Motivation

"Read a value and remove it in the same step" is one of the most common things people do with a map (e.g. consume a pending callback keyed by request id, pop a job off a work queue, evict and inspect a cache entry, hand off ownership of a buffered chunk, drain a "waiting for X" table, etc.).

JavaScript can express every other member of the CRUD quartet as a single call that returns something useful (e.g. map.get(k), map.set(k, v) (returns the map), and map.has(k)) except the "remove and read" combination.

Map.prototype.delete returns a boolean telling you whether anything was removed (i.e. it throws the value away).

So today you must write the value-preserving version by hand, which requires two lookups of the same key:

function getAndDelete(map, key) {
  const value = map.get(key); // lookup #1
  map.delete(key);            // lookup #2
  return value;
}

This has real downsides:

  1. Two lookups instead of one. For hot paths (dispatch tables, per-frame caches, etc.) the extra lookup is pure overhead an engine could avoid if the operation were a single method.
  2. It's a footgun to inline. const v = map.get(k); map.delete(k); return v; is easy to reorder incorrectly (i.e. delete before capturing the value) and the delete boolean result is silently discarded so linters can't help.
  3. Everyone re-implements it. The helper is reinvented under a dozen names (e,g, getAndDelete, getAndRemove, pop, pull, popEntry, take, fetch, etc.) in codebase after codebase.

Proposal

Map.prototype.getAndDelete(key)     // the removed value, or undefined if key was absent
WeakMap.prototype.getAndDelete(key) // the removed value, or undefined if key was absent

getAndDelete(key) is defined to be observably equivalent to reading get(key) and then performing delete(key), returning the value that get would have returned.

It mutates the map in place and returns the value directly (i.e. it does not return the map) because the whole point is to hand the value back to the caller.

Examples

// Consume request.
const resolveForRequestId = new Map();
function onResponse(id, payload) {
  const resolve = resolveForRequestId.getAndDelete(id); // grab it and clear it in one step
  resolve?.(payload);
}

// Move ownership.
const buffers = new Map();
// ...
const chunk = buffers.getAndDelete(streamId); // caller now owns `chunk`

// One-off logic.
const stateForObject = new WeakMap();
function consume(obj) {
  const state = stateForObject.getAndDelete(obj);
  return state;
}

Semantics

Situation getAndDelete(key) returns Side effect
key present with value v v entry removed
key present with value undefined undefined entry removed
key absent undefined none
WeakMap and key cannot be held weakly undefined none
this is not a Map/WeakMap throws TypeError

Like get, a return of undefined is ambiguous between "absent" and "present with value undefined".

Unlike get, callers cannot resolve that ambiguity by calling has(key) after receiving undefined, because getAndDelete has already removed a matching entry.

Calling has(key) beforehand works, but requires a second lookup.

Whether getAndDelete should provide a way to distinguish these cases with one lookup remains an open question below.

Distinguishing a Missing Key from an undefined Value

A Map can store any ECMAScript language value, including any Symbol or object that an API could expose as a sentinel for a missing value.

If getAndDelete must return every present value unchanged, no sentinel provided by the language can avoid every collision because that sentinel could itself be stored in the map.

As a result, there are three options that seem likely to be considered by TC39.

1. Keep returning the value directly

The proposal could keep the current behavior and require callers that need the distinction to call has first:

if (map.has(key)) {
  const value = map.getAndDelete(key);
  use(value); // `value` may be undefined
} else {
  handleMissing();
}

This would keep the API small, avoid creating a result object, and continue to return the removed value directly.

It would also follow existing JavaScript APIs.

Array.prototype.pop has the same ambiguity because an empty array and an array whose last value is undefined both produce undefined.

Map.prototype.get and Array.prototype.find have similar behavior.

The downside is that callers that need the distinction would still perform two lookups.

This is probably the option most likely to be accepted because it matches the direct precedent and does not add complexity for every caller.

2. Optional fallback value

getAndDelete could accept a second value that is returned only when the key is absent:

const missing = Symbol("missing");
const value = map.getAndDelete(key, missing);

if (value === missing)
  handleMissing();
else
  use(value); // `value` may be undefined

Calling getAndDelete(key) with one argument would continue to return undefined when the key is absent.

This would preserve the direct return value, require one lookup, and avoid creating a result object.

A private Symbol could be created once and reused, while an ordinary value could also be used as a default.

The main issue is that the caller must reserve a value that the map will not contain in order to guarantee the distinction.

The default value would also be evaluated before the method runs, even when the key is present.

This is probably the most plausible addition if the committee wants a way to distinguish the cases with one lookup.

It follows Python's dict.pop and the default value form of the Stage 4 Map.prototype.getOrInsert.

3. Always return a tagged result

The method could instead return an object containing both pieces of information:

const { found, value } = map.getAndDelete(key);

if (found)
  use(value); // `value` may be undefined
else
  handleMissing();

For example, the result could always have the shape { found, value }, where value is undefined when found is false.

This would always distinguish the cases, preserve every stored value, and require one lookup.

Tagged results are also already used by iterator APIs, so the shape would not be new to JavaScript.

The downside is that every call would need to unpack a result instead of using the removed value directly.

It would also create a result object for every call.

The getOrInsert discussion raised similar concerns about allocations in frequently called map code, and its final design removed the options object.

Since avoiding overhead is one of the main motivations for getAndDelete, this would be a significant cost.

Earlier versions of getOrInsert also tried to handle insertion and update callbacks in a single API.

Review feedback was that the API was unintuitive because it tried to do too many things, and the Stage 4 design now focuses on the common operation instead.

Based on that history, the proposal should focus on these three choices instead of trying to cover every possible way to return the status.

Prior Art

"Remove and return the value" is the default shape of this operation in most languages' standard libraries.

JavaScript is the outlier in returning only a boolean from delete.

Language API Removes & returns value? Notes
Rust HashSet::take(&value) -> Option<T> Direct precedent for the name take: "removes and returns the value in the set, if any".
Rust HashMap::remove(&k) -> Option<V> / remove_entry Returns the value (or the (k, v) pair).
Python dict.pop(key[, default]) Returns value; raises KeyError if absent and no default given.
Ruby Hash#delete(key) Returns the value (or nil); optional block for the missing case.
Java Map.remove(Object key) -> V Returns the previous value, or null.
C# Dictionary.Remove(key, out value) -> bool The out overload (added in .NET Core 3.0) hands back the removed value.
Swift Dictionary.removeValue(forKey:) -> Value? Returns the removed value, or nil.
Kotlin MutableMap.remove(key): V? Returns the previous value, or null.
C++ std::map::extract / unordered_map::extract (C++17) Removes and hands back a node handle owning the key/value.
Perl delete $hash{$key} The delete operator returns the deleted value.
Go delete(m, k) No return; you must write v, ok := m[k]; delete(m, k) — the same two-step JS is stuck with.
JavaScript Array.prototype.pop() -> element JS's existing remove-and-return: pops the last element and returns it (or undefined). take is the by-key analogue for maps.
JavaScript (today) Map.prototype.delete -> boolean Returns only whether something was removed; the value is discarded.

Precedents

getAndDelete is not a foreign shape for JavaScript as the language already has a "remove an element and return its value" method in Array.prototype.pop.

It mutates the collection in place, returns the removed element, and yields undefined when there is nothing to remove.

getAndDelete applies that exact contract to keyed collections (i.e. addressed by key instead of by position):

Array.prototype.pop() Map.prototype.getAndDelete(key)
Selects what to remove by position (last index, LIFO) key
Argument none the key
Returns the removed element the removed value
When nothing matches undefined (empty array) undefined (absent key)
Mutates in place
Returns the container for chaining ❌ (returns the element) ❌ (returns the value)

Because pop already establishes "mutate and hand back the value" as an idiomatic JavaScript pattern, getAndDelete is a small, consistent extension of it rather than a new concept (i.e. it just removes by key).

Naming

  • getAndDelete names the two existing operations it combines and extends the family of get, getOrInsert, getOrInsertComputed, etc. The order of the read and mutation is explicit and greppable.
  • take matches Rust's HashSet::take ("remove and return") and reads naturally "take the value out of the map".
  • pop is misleading as Array.prototype.pop takes no argument and removes from the end (i.e. LIFO) instead of by index.
  • remove would sit confusingly beside the existing delete (i.e. two spellings with different return types) and collides with the many userland remove methods.

The proposal adopted getAndDelete when advancing to Stage 1.

Usage

Counts below come from GitHub GET /search/code which is a token-based index, covers only the default branch of public repos, and does not verify that the matched tokens act on the same key/variable.

Query Files
"getAndDelete(" language:TypeScript ~260
"getAndDelete(" language:JavaScript ~281
"popEntry(" in:file (JS/TS) ~3,270
getAndRemove in:file (all langs, token match) ~48,000
Query Files
cache.get cache.delete language:TypeScript ~143,000
map.get map.delete language:JavaScript ~99,800
"get(key)" "delete(key)" language:TypeScript ~44,600

Examples

// pull out the pending request/callback when its response arrives
let responseData = this._pendingResponses.getAndDelete(sequenceId) || {};

// remove and read a worker as it goes away
let worker = this._workerForId.getAndDelete(workerId);

// adopt resources that were buffered for a target
let resources = this._orphanedResources.getAndDelete(target.identifier);

Questions

  • Is the ambiguity already present in Map.prototype.get and Array.prototype.find acceptable for getAndDelete?
  • If not, should getAndDelete accept a fallback value or always return a tagged result?
  • This sketch returns undefined, mirroring get, so getAndDelete is a drop-in replacement for the get and delete pair, but an alternative could be a Python-style optional default getAndDelete(key, defaultValue).
  • Is it worth adding Set.prototype.take and/or WeakSet.prototype.take for parity like how Set.prototype.entries returns a two-value array to match Map.prototype.entries?

Polyfill

'use strict';

(function installMapGetAndDeletePolyfill() {
  function defineGetAndDelete(Ctor) {
    if (typeof Ctor !== 'function' || typeof Ctor.prototype.getAndDelete === 'function')
        return;

    const get = Ctor.prototype.get;
    const del = Ctor.prototype.delete;

    function getAndDelete(key) {
      const value = get.call(this, key);
      del.call(this, key);
      return value;
    }

    Object.defineProperty(Ctor.prototype, 'getAndDelete', {
      value: getAndDelete,
      writable: true,
      enumerable: false,
      configurable: true,
    });
  }

  defineGetAndDelete(typeof Map === 'function' ? Map : undefined);
  defineGetAndDelete(typeof WeakMap === 'function' ? WeakMap : undefined);
})();