Mert Özen Further With Every Line
Frontend JavaScript Advanced — #16

JavaScript's Next Features: TC39 Stages, Temporal and using

Mert Özen Nov 3, 2026 10 min 6 views
JavaScript's Next Features: TC39 Stages, Temporal and using

We learn how a feature passes TC39's stages into the language, how Temporal (PlainDate, ZonedDateTime, Duration) fixes Date's problems, and resource management with using, with notes on Safari support.

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

We're doing a simple job with the Date we saw earlier: finding the date one month after January 31.

const start = new Date(2026, 0, 31); // January 31, 2026
const next = start;
next.setMonth(1); // one month later

console.log(start.getMonth(), start.getDate());

What does this code print to the console? Careful: we're printing start (start), not next (next). Make a guess before you keep reading. We'll give the answer when we see how Temporal solves this problem.

How Does a Feature Get into the Language? TC39 Stages

In the very first post of the series we said that JavaScript is developed by a committee called TC39 through yearly editions. Now we can look inside that process. Every new feature is born as a proposal and goes through stages. You can compare it to a dish making its way onto a restaurant menu.

At stage 0 the idea exists only in the chef's head. At stage 1 there's a "champion" who argues for the idea, and the committee has agreed to examine the problem. At stage 2 a solution has been chosen and the recipe is written down, though details can still change. At stage 2.7, added later, the recipe is complete and the tasting begins: the spec text is finished and tests have been written for Test262, the official test suite. At stage 3 the dish is on the trial menu of a few branches; browsers start implementing the feature. At stage 4 the dish is on the main menu: it has compatible implementations in at least two engines that pass the tests, and the feature goes into the next yearly edition.

The yearly edition is approved every June; ES2026 was approved in June 2026. The two stars of this post, Temporal and Explicit Resource Management, reached stage 4 in 2026 but missed ES2026; they're expected to be part of ES2027.

Temporal: The Replacement for Date

The answer to the opening question is 2 3, that is, March 3. Three separate surprises piled on top of each other. First, next = start didn't make a copy; both point to the same object, and setMonth changed that object in place. Second, months start at 0; 1 means February, and the 2 on screen means March. Third, since there's no such day as February 31, Date silently carried the three extra days over into March. Add to that its knowing only local time and UTC, and having no "date only" type.

Temporal was designed to fix these problems from the ground up. All its values are immutable; every operation returns a new object. Months start at 1. Time zones are stated explicitly by name, like "Europe/Istanbul". Most importantly, there's a separate type for each job. If Date is a pocketknife trying to do everything, Temporal is a well-organized toolbox:

Temporal.PlainDate is a day on a wall calendar, with no time and no time zone, like a birthday. Temporal.PlainTime is the time on a wristwatch, like "every day at 09:00". Temporal.PlainDateTime combines the two but still has no time zone. Temporal.ZonedDateTime is the departure time on a plane ticket: date, time and time zone together, matching exactly one moment in the world. Temporal.Instant is an exact point on the timeline, and Temporal.Duration is a length of time such as "2 hours 30 minutes". For the current time there's Temporal.Now.

Paste this into the Chrome or Firefox console and try it:

const date = Temporal.PlainDate.from("2026-01-31");
const oneMonthLater = date.add({ months: 1 });

console.log(oneMonthLater.toString()); // "2026-02-28"
console.log(date.toString());          // "2026-01-31", unchanged
console.log(date.month);               // 1

const today = Temporal.Now.plainDateISO();
console.log(today.add({ days: 1 }).toString()); // tomorrow's date

const first = Temporal.PlainDate.from("2026-01-01");
const last = Temporal.PlainDate.from("2026-03-15");
console.log(first.until(last).toString());                           // "P73D"
console.log(first.until(last, { largestUnit: "month" }).toString()); // "P2M14D"
console.log(Temporal.PlainDate.compare(first, last));                // -1
console.log(date.toLocaleString("tr-TR", { dateStyle: "long" })); // "31 Ocak 2026"

When we add a month to January 31, Temporal doesn't spill into March; it stops at the last day of February. This default behavior is called overflow: "constrain"; if you'd rather treat the overflow as an error, pass { overflow: "reject" } and you'll get a RangeError. date stayed unchanged, because add returned a new object. until gives you the difference between two dates as a Duration; P73D is ISO 8601 for "73 days". The default largest unit is days; if you want months and days, say so with largestUnit. For formatting, the Intl we saw earlier works just the same.

ZonedDateTime and Duration

Time zones were where Date hurt the most. Remember the daylight saving trap we saw in the Intermediate series. In Germany, the clocks go forward one hour on March 29, 2026. Let's ask Temporal about it:

const berlin = Temporal.ZonedDateTime.from("2026-03-28T12:00[Europe/Berlin]");

console.log(berlin.add({ days: 1 }).toString());
// "2026-03-29T12:00:00+02:00[Europe/Berlin]"
console.log(berlin.add({ hours: 24 }).toString());
// "2026-03-29T13:00:00+02:00[Europe/Berlin]"

console.log(berlin.withTimeZone("Europe/Istanbul").toString());
// "2026-03-28T14:00:00+03:00[Europe/Istanbul]"

"One day later" and "24 hours later" give different results here, and both are correct. Adding a day means going to the next day on the calendar, at the same time. Adding 24 hours means 24 hours actually passing; because an hour disappears that night, the wall clock shows 13:00. Temporal makes you say which one you mean. withTimeZone shows the same moment from another time zone; since Turkey hasn't used daylight saving time since 2016, Istanbul is always +03:00. To get the current time in a given zone there's Temporal.Now.zonedDateTimeISO("Europe/Istanbul"), and for the browser's own zone, Temporal.Now.timeZoneId().

For lengths of time there's Duration:

const duration = Temporal.Duration.from({ hours: 1, minutes: 30 });
console.log(duration.toString());                 // "PT1H30M"
console.log(duration.total({ unit: "minutes" })); // 90

const meeting = Temporal.PlainTime.from("09:45");
console.log(meeting.add(duration).toString());    // "11:15:00"

const longDuration = Temporal.Duration.from({ minutes: 150 });
console.log(longDuration.round({ largestUnit: "hours" }).toString()); // "PT2H30M"

Carrying a duration as a value instead of a bare number of milliseconds saves whoever reads the code from asking "what is this 5400000?". total converts the duration into a single unit, and round spreads it over readable units. When you need a bridge to old code, new Date().toTemporalInstant() turns a Date into an Instant.

using: Closing Resources Automatically

In programming, some of the things you open must be closed: a connection, a timer, an event listener, an observer. The classic way to do this is try/finally:

const connection = openConnection();
try {
  connection.send("hello");
} finally {
  connection.close();
}

It works, but it's noisy; with two resources you write two nested try blocks. Explicit Resource Management moves this job into the language: a variable declared with using is closed automatically when its block ends, however it ends. The closing is done by the object's Symbol.dispose method. Try it in the console:

function resource(name) {
  console.log(`${name} opened`);
  return {
    [Symbol.dispose]() {
      console.log(`${name} closed`);
    },
  };
}

{
  using a = resource("A");
  using b = resource("B");
  console.log("work done");
}
// A opened, B opened, work done, B closed, A closed

Resources are closed in the reverse order they were opened. It's like leaving home in the evening: you switch off the light first, then lock the door; the last thing you opened is the first thing you close. The order matters, because a resource opened later often depends on an earlier one.

The rules resemble const: the variable must get a value when declared and can't be reassigned. The value must be either an object with a Symbol.dispose method, or null or undefined; anything else gives you a TypeError. If the block ends with an error, the disposal still happens. If another error occurs during disposal, the two are combined into a SuppressedError: the error field holds the later error and suppressed holds the earlier one.

A small real-world example: a measuring tool built on the performance.now we saw in the performance post:

function measure(label) {
  const start = performance.now();
  return {
    [Symbol.dispose]() {
      console.log(`${label}: ${(performance.now() - start).toFixed(1)} ms`);
    },
  };
}

function heavyWork() {
  using _ = measure("heavyWork");
  let total = 0;
  for (let i = 0; i < 1e6; i++) total += i;
  return total; // the measurement is logged no matter where the function exits
}

heavyWork();

DisposableStack and await using

If your resources don't have their own Symbol.dispose method, or you don't know how many there will be in advance, DisposableStack gives you a cleanup stack. use() adds a disposable object, adopt(value, fn) adds a non-disposable value together with a closing function, and defer(fn) adds just a function. dispose() runs them all in reverse order, disposed tells you whether the stack has been disposed, and move() transfers the contents to a new stack and empties the old one:

function setup() {
  using stack = new DisposableStack();

  const timer = setInterval(() => console.log("tick"), 1000);
  stack.defer(() => clearInterval(timer));

  const observer = new ResizeObserver(() => console.log("size changed"));
  observer.observe(document.body);
  stack.defer(() => observer.disconnect());

  return stack.move(); // if all went well, cleanup responsibility passes to the caller
}

const cleanup = setup();
// Later, when we're done with this section:
cleanup.dispose();

The pattern may look odd at first, but it's very clever. If an error occurs while setup is running, using cleans up everything set up so far; a half-finished setup doesn't leave a timer running behind it. If everything goes well, move() transfers the resources to a new stack, and the emptied old stack has nothing left to close. It's the "return a cleanup function" habit from the observers post, built into the language.

If closing is asynchronous, say a log that can't close before sending its data to a server, you use Symbol.asyncDispose and await using inside an async function; the stack version is AsyncDisposableStack:

async function run() {
  await using writer = {
    async [Symbol.asyncDispose]() {
      await new Promise((resolve) => setTimeout(resolve, 500));
      console.log("writer closed");
    },
  };
  console.log("working");
}

await run();
console.log("done");
// working, writer closed, done

Using It Today: Support and Polyfills

Temporal arrived in Chrome and Edge 144 and Firefox 139. using and await using have worked since Chrome and Edge 134 and Firefox 141. Safari doesn't support either of them yet, which is why both show up as "Limited" in Baseline.

Temporal is the easier case, because it's an API; if it's missing, you can drop in a polyfill script that does the same thing. Check whether it exists first:

if (typeof Temporal === "undefined") {
  // Safari: load a Temporal polyfill or carry on with Date and Intl
}

using, on the other hand, is syntax, a keyword, and syntax can't be added with a polyfill. Even more important: Safari can't parse a script that contains using at all, so not a single line in that file runs. Symbol.dispose and DisposableStack can be polyfilled; for the keyword itself you need tools that convert code to older syntax, and those aren't the subject of this series. If you have Safari users, use DisposableStack with its polyfill and call it by hand inside try/finally for now. There's a small trick to find out about syntax support at runtime:

let usingVar = true;
try {
  new Function("{ using x = null; }");
} catch {
  usingVar = false;
}
console.log("using supported?", usingVar);

new Function parses the text you give it at that moment; without support it throws a SyntaxError and the main script keeps running. Depending on the result, you can load a different module with the dynamic import() we saw earlier.

ES2027 and Beyond

There are other features that reached stage 4 in 2026 and are expected to enter ES2027, and most of them continue the iterator helpers we saw earlier: splitting iterators into fixed-size pieces (chunking), includes and join on iterators, and Iterator.zip, which walks several iterators together:

const names = ["Ada", "Alan", "Grace"];
const scores = [92, 78, 85];

for (const [name, score] of Iterator.zip([names, scores])) {
  console.log(`${name}: ${score}`);
}

For now these exist only in the latest Chrome and Firefox versions, not in Safari. Atomics.pause is also on the list; it gives the processor a "take a breather" hint for code that waits briefly on memory shared between Web Workers.

To know when you can use a feature with confidence, two places are enough: TC39's proposals repository on GitHub tells you its stage, and the Baseline badge on MDN pages tells you its browser support.

Common Pitfalls

Because Temporal objects are immutable, the most common mistake is forgetting to assign the result. date.add({ days: 1 }) on its own changes nothing; you need to write const tomorrow = date.add({ days: 1 }).

Comparing Temporal objects with === doesn't work either; two PlainDate objects for the same day are two separate objects, and you get false. Use date.equals(other) or Temporal.PlainDate.compare(a, b).

Choosing the wrong type is a sneakier mistake. Store a flight's departure time as a PlainDateTime and the time zone is lost, so a user in another country sees the wrong time. For anything tied to a specific moment, use ZonedDateTime or Instant. Watch out for the gap during the daylight saving switch too: there's no such moment as 2026-03-29T02:30 in Berlin, and ZonedDateTime.from shifts it to 03:30 by default.

On the using side, the first trap is location: you can't use using at the top level of a regular script; you get a SyntaxError. You need to be inside a block, a function or a module, which is why we wrapped our examples in curly braces. It also can't be used directly inside a switch or in a for...in header. The second trap is closures: if a callback captures a using variable and is called after the block has ended, it finds the resource already closed.

A Small Experiment

★☆☆ Use Temporal to calculate how many days are left until your next birthday. Get today with Temporal.Now.plainDateISO(), find this year's birthday with with({ month: 5, day: 19 }), move to the next one with add({ years: 1 }) if it has passed, and print the difference in days with until.

★★☆ Predict the output and its order before running it, then try it in the Chrome or Firefox console:

try {
  using a = { [Symbol.dispose]() { console.log("a"); } };
  using b = { [Symbol.dispose]() { console.log("b"); } };
  console.log("c");
  throw new Error("d");
} catch (error) {
  console.log(error.message);
}

★★★ Write a function called tempListener(target, type, fn) (temporary listener). It should call addEventListener and return an object that removes that listener with Symbol.dispose. Use it with using inside a block and verify that clicks are no longer heard after you leave the block. Then collect three different listeners in a single DisposableStack and remove them all with one dispose() call.

JavaScript keeps growing with a new edition every summer, and these series will grow along with it. When ES2027 is approved, and as the next proposals reach stage 4, we'll be back with new posts. Until then, whenever you come across a new feature, make it a habit to check its stage first, then its Baseline status.