This article is published in English.
Seven Native JavaScript Features That Replace Utility Dependencies
How Temporal, using declarations, Map upserts, Math.sumPrecise, iterator helpers, Set methods and Promise.withResolvers remove common JavaScript boilerplate.
Starting a serious JavaScript project used to mean installing a date library, a collection utility and a grouping helper, then wrapping every opened resource in defensive try...finally blocks. Over the last few release cycles, a series of TC39 proposals that were debated for years have become part of the standard and are landing in evergreen browsers, Node.js, Bun and Deno. This guide walks through seven of them, the problem each one solves, and what to watch for before you delete a dependency.
A note on support before you start: these features reached engines at different times, and some are very recent. Check the current compatibility data for your target browsers and runtime versions, and keep a polyfill or the old library where support is not yet guaranteed.
Temporal: a real date and time API
Anyone who has used the built-in Date object for more than a few minutes has hit its traps:
- Months are zero-based (
0is January and11is December), yet days of the month start at1. Dateinstances are mutable, so handing one to a helper function can silently change the caller's value.- Time zone conversion and daylight saving arithmetic need awkward manual math or a library such as date-fns, Day.js or Luxon.
Temporal is a new, immutable date and time API designed to supersede Date. It does not remove Date from the language, since existing code keeps working, but new code no longer needs it.
Separate types for separate concepts
Temporal's key design decision is modeling different ideas as different types:
Temporal.PlainDate: a calendar date with no time and no time zone, such as a birthday.Temporal.PlainTime: a wall-clock time with no date, such as an alarm at 07:00.Temporal.ZonedDateTime: an exact moment tied to a specific time zone and calendar.
The snippet below reads the current time in New York, adds 14 days to a plain date, and measures the gap between two dates in months and days. Notice that the difference is requested with largestUnit: 'month', which is what makes the result read as months plus days rather than a raw day count.
// 1. Getting current time in a specific time zone
const meetingTime = Temporal.Now.zonedDateTimeISO('America/New_York');
console.log(meetingTime.toString());
// e.g. "2026-09-04T12:09:28-04:00[America/New_York]"
// 2. Safe, readable date math
const deadline = Temporal.PlainDate.from('2026-09-01');
const followUp = deadline.add({ days: 14 });
console.log(followUp.toString()); // "2026-09-15"
// 3. Calculating the exact difference between two dates
const start = Temporal.PlainDate.from('2026-01-01');
const end = Temporal.PlainDate.from('2026-09-04');
const diff = start.until(end, { largestUnit: 'month' });
console.log(`${diff.months} months and ${diff.days} days`);
// "8 months and 3 days"
Every Temporal object is immutable. Calling add() never modifies the original; it returns a new object. For many projects that means a date library can leave the bundle. Before migrating, review common Temporal traps.
Explicit resource management with using
A frequent source of leaks and stuck locks is forgetting to close something you opened:
- a file handle that stays open after reading
- a database connection left hanging when an API route throws
- event listeners or worker threads that are never detached
The traditional fix is try...finally. With three resources in one function, that quickly turns into deeply nested boilerplate. Explicit resource management adds the using declaration together with two well-known symbols, Symbol.dispose and Symbol.asyncDispose.
How disposal is triggered
When a value is declared with using, the engine calls its [Symbol.dispose]() method as soon as execution leaves the enclosing block, whether the block ends normally or because an error was thrown. If several resources are declared in one scope, they are disposed in reverse order of declaration, which is exactly what nested try...finally blocks would have done.
In the example, DatabaseSession implements the disposal hook, and runReport declares a session with using. The log order shows that cleanup runs after the query, as the function exits:
// Define a resource that knows how to clean itself up
class DatabaseSession {
constructor(dbName) {
this.connection = `Connected to ${dbName}`;
console.log("Database opened.");
}
query(sql) {
return `Results for: ${sql}`;
}
// Built-in disposal hook
[Symbol.dispose]() {
console.log("Database connection closed automatically!");
}
}
// Using the resource
function runReport() {
using session = new DatabaseSession("AnalyticsDB");
const data = session.query("SELECT * FROM metrics");
console.log(data);
// When runReport finishes (or if it throws),
// session[Symbol.dispose]() runs immediately.
}
runReport();
// Logs:
// 1. "Database opened."
// 2. "Results for: SELECT * FROM metrics"
// 3. "Database connection closed automatically!"
For asynchronous cleanup, such as closing a network socket, implement [Symbol.asyncDispose]() and declare the resource with await using inside an async function. The runtime then awaits the cleanup before continuing.
Map upserts with getOrInsert and getOrInsertComputed
Map is the right structure for keyed collections, yet the "read it, or create it if missing" pattern has always been clumsy. Grouping values under a key typically looks like this:
// The old way: multiple lookups and manual branching
if (!userCache.has(userId)) {
userCache.set(userId, fetchDefaultProfile(userId));
}
const profile = userCache.get(userId);
That is two lookups (has followed by get) plus a branch, just to guarantee an entry exists. The new methods fold it into one call and one lookup:
map.getOrInsert(key, defaultValue): returns the existing value forkey, or storesdefaultValueunder that key and hands it back.map.getOrInsertComputed(key, callback): the same, but the callback runs only when the key is missing, so an expensive default is never built needlessly.
The distinction matters for the example above. getOrInsert(userId, fetchDefaultProfile(userId)) would call fetchDefaultProfile on every access, because function arguments are evaluated before the call; the computed variant avoids that. The next snippet groups events under user IDs, creating an empty array only the first time a user appears:
const userActivity = new Map();
// Grouping events under user IDs
function logEvent(userId, eventName) {
// If userId doesn't exist, create an empty array, insert it, and return it.
const events = userActivity.getOrInsertComputed(userId, () => []);
events.push(eventName);
}
logEvent("user_42", "login");
logEvent("user_42", "clicked_button");
console.log(userActivity.get("user_42"));
// ['login', 'clicked_button']
Math.sumPrecise for accurate totals
JavaScript numbers follow IEEE 754 double-precision floating point, which produces the famous result below:
0.1 + 0.2; // 0.30000000000000004
In invoices, carts or chart aggregations, such errors accumulate until a total is visibly wrong.
Math.sumPrecise() takes an iterable of numbers and computes their sum as if with unlimited precision, rounding only once at the very end. A naive reduce rounds after every step, which is where the drift comes from. In the example, the four invoice items add up to 0.9999999999999999 with reduce, while Math.sumPrecise returns 1 (the console prints 1, not 1.0):
const invoiceItems = [0.1, 0.2, 0.3, 0.4];
// Old reduce approach:
const naiveTotal = invoiceItems.reduce((acc, n) => acc + n, 0);
console.log(naiveTotal);
// 0.9999999999999999
// New Math.sumPrecise approach:
const accurateTotal = Math.sumPrecise(invoiceItems);
console.log(accurateTotal);
// 1.0
It is important to understand the limit. Math.sumPrecise removes accumulated rounding error, but it cannot change the fact that values such as 0.1 are not exactly representable in binary. Summing just [0.1, 0.2] still yields 0.30000000000000004, because the exact sum of those two doubles rounds to that value. It is a real improvement for statistics and dashboards; for money, integer minor units (cents) or a decimal library remain the safer choice.
Iterator helpers and Iterator.concat
For over ten years arrays have offered .map(), .filter() and .slice(), while iterators and generators, such as infinite sequences or database cursors, had nothing comparable. To use .map() on a generator yielding thousands of rows, you had to spread the whole thing into an array first with [...generator()], which throws away the memory benefit of streaming.
Iterator helpers add methods like map, filter, take, drop and flatMap directly to iterators, and Iterator.concat() joins several iterators into one. All of them are lazy: an item is computed only when the consumer asks for it.
The example chains helpers onto an infinite generator. Because evaluation is lazy and take(3) stops after three values, the loop terminates even though the source never ends:
function* infiniteCounter() {
let count = 1;
while (true) {
yield count++;
}
}
// Grab an iterator from our infinite generator
const stream = infiniteCounter()
.filter(num => num % 2 === 0) // Keep even numbers
.map(num => `Count: ${num}`) // Format them
.take(3); // Stop after 3 values
for (const item of stream) {
console.log(item);
}
// Logs:
// "Count: 2"
// "Count: 4"
// "Count: 6"
To stitch several sequences together without building an intermediate array, pass them to Iterator.concat(). It consumes each iterator in turn:
const firstBatch = [1, 2, 3].values();
const secondBatch = [4, 5, 6].values();
const combined = Iterator.concat(firstBatch, secondBatch);
console.log([...combined]); // [1, 2, 3, 4, 5, 6]
Iterator helpers themselves have been available in major engines for a while; Iterator.concat() is newer, so check it separately.
Native Set operations
For a long time a Set offered little beyond .has(), .add() and .delete(). Anything like an intersection meant converting to arrays and filtering by hand:
// The old manual way:
const intersection = new Set([...setA].filter(x => setB.has(x)));
Set.prototype now includes real set algebra: union, intersection, difference, symmetricDifference, plus the predicates isSubsetOf, isSupersetOf and isDisjointFrom. Each operation returns a new Set and leaves the originals unchanged.
The example compares two role sets, extracting the permissions only admins have and confirming that the editor's roles are a subset of the admin's:
const adminRoles = new Set(['read', 'write', 'delete', 'audit']);
const editorRoles = new Set(['read', 'write']);
// Find privileges exclusive to admins
const adminOnly = adminRoles.difference(editorRoles);
console.log([...adminOnly]); // ['delete', 'audit']
// Check role containment
console.log(editorRoles.isSubsetOf(adminRoles)); // true
They also skip the temporary arrays that the spread-and-filter approach creates.
Promise.withResolvers for deferred promises
Sometimes a promise must be settled from outside its constructor, for example by an event handler or a callback registered elsewhere. That "deferred" pattern used to need variables hoisted out of the executor:
// The clunky way
let resolveFn, rejectFn;
const myPromise = new Promise((res, rej) => {
resolveFn = res;
rejectFn = rej;
});
Promise.withResolvers() returns the promise together with its resolve and reject functions in a single object. In the example, a click handler resolves the promise and other code simply awaits it. Note that the snippet uses top-level await, so it has to run in an ES module or inside an async function:
const { promise, resolve, reject } = Promise.withResolvers();
// Attach your listeners or pass the resolve handle to an event handler
document.getElementById("submit-btn").addEventListener("click", () => {
resolve("User clicked submit!");
}, { once: true });
// Await the promise wherever you need it
const message = await promise;
console.log(message);
The pattern is handy for custom event queues, bridges to Web Workers, and adapting callback or emitter-based APIs to async code.
What this means for your dependencies
The common thread is parity: the language now covers routine jobs such as dates, resource cleanup, keyed grouping and set math that used to be handed to userland libraries. Before adding another package in your next sprint, check your targets:
- If you ship to modern browsers or current Node.js, Bun or Deno releases, many of these tools already exist globally.
- Each dependency you avoid means a smaller bundle, faster startup and code that any JavaScript developer can read without learning a third-party API.
Key takeaways
- Temporal gives dates immutable, clearly separated types; it supersedes
Datefor new code without removing it. usingandawait usingmake cleanup automatic and ordered, replacing nestedtry...finally.- Prefer
getOrInsertComputedwhen the default value is expensive to create. Math.sumPrecisefixes accumulated rounding drift, not binary representation; keep money in integers.- Iterator helpers and Set methods remove whole categories of array conversions.
- Verify runtime support per feature, because these proposals landed on very different timelines.