Back to BlogNode.js

Node 26 Made Temporal Global, and My Sort Comparator Started Throwing TypeError

Node 26 hit Active LTS on October 28 with Temporal enabled by default. Every Temporal type throws on valueOf, so the `(a, b) => a - b` comparator you've typed a thousand times now dies at runtime. Here's what actually breaks, why the spec authors chose to throw, and how to phase the migration without rewriting every date helper in one go.

Node.jsTemporal APIDatesMigrationJavaScript
Node 26 Made Temporal Global, and My Sort Comparator Started Throwing TypeError

Node 26 went Active LTS on October 28, so the "bump the runtime" tickets are landing on real sprints right about now. The headline feature is Temporal, shipped on by default since 26.0.0 with no flag and no polyfill (release notes).

I did the swap on a small internal service last week, mostly to find out how bad it would be. The migration itself was dull in a good way. Then a nightly job that had run without complaint for two years fell over on a line of code I would never have thought to look at.

The line

const due = rows.map((r) => Temporal.PlainDate.from(r.due_date));
due.sort((a, b) => a - b);
TypeError: Cannot convert a Temporal.PlainDate to a primitive value

That comparator is muscle memory. With Date it works by accident: Date.prototype.valueOf hands back epoch milliseconds, so a - b silently becomes number subtraction and you get a sort for free. Temporal refuses to play. Every Temporal type defines valueOf to throw.

The fix is one token shorter than the thing it replaces:

due.sort(Temporal.PlainDate.compare);

compare takes two values and returns -1, 0 or 1, which is exactly the comparator contract. Each Temporal type has its own static compare.

Throwing is the right call, and it is still annoying

My first reaction was that this is gratuitous. It isn't. Date's coercion is the reason new Date('2026-02-30') - new Date('2026-03-01') evaluates to NaN instead of telling you that one of those strings is garbage. You then sort by NaN, the comparator returns a nonsense ordering, and the bug surfaces three screens away from where it happened.

There's a semantic argument too. Subtracting two ZonedDateTime values in different zones, or a PlainDate from a PlainDateTime, is a question with more than one reasonable answer. The spec authors decided you should have to say which one you meant rather than have the engine guess in milliseconds.

I buy the reasoning. It also means every date-touching helper in the repo needs a read, because the places that coerce are not where you'd expect.

The operators you have to retrain

a < b                         // throws, hint number tries valueOf first
Temporal.PlainDate.compare(a, b) < 0   // what you meant

a === b                       // always false for two distinct objects
a.equals(b)                   // what you meant

Math.max(...dates)            // throws, same coercion path
dates.reduce((m, d) => (Temporal.PlainDate.compare(d, m) > 0 ? d : m))

`due ${a}`                    // fine, string hint hits toString
a + ''                        // throws, default hint hits valueOf

The string-template case is the one that gets people, because it reads like coercion and works anyway. ${a} asks for a string hint, so toString runs first and you get 2026-11-10. Concatenation with + asks for the default hint, valueOf runs first, and you get the TypeError. Same value, same file, different outcome.

Temporal objects also aren't useful as Map or Set keys, since identity comparison applies. Key on .toString() if you need dedupe.

The boundary is where it actually hurts

Inside your own code this is a mechanical fix. At the edges it is a data problem.

ZonedDateTime stringifies with a bracketed time zone:

const zdt = Temporal.Now.zonedDateTimeISO('Asia/Kolkata');
String(zdt); // 2026-11-10T21:14:03.221+05:30[Asia/Kolkata]

That bracket is part of the Temporal string format, not ISO 8601. Hand it to Postgres, to a Go service, or to new Date(), and you get a parse error or an invalid date. For anything leaving the process, go through Instant:

zdt.toInstant().toString();    // 2026-11-10T15:44:03.221Z
zdt.epochMilliseconds;         // for code that still wants a Date
new Date(zdt.epochMilliseconds);

JSON.stringify has the same trap with a friendlier face. Temporal types define toJSON, so stringify never throws, it just emits the bracketed form into your API response and the consumer rejects it in staging two days later.

How I'd phase it

Don't do the big-bang rewrite. The throw is doing you a favour, so use it as the work queue: upgrade the runtime, swap one module to Temporal, run the suite, and the TypeErrors are your checklist. Nothing fails silently, which is more than you can say for most date migrations.

Pick a direction for the boundary and write it in the README. Either Temporal inside and Date at the edges, with conversions in two named functions, or the reverse. What you don't want is both conventions in the same request path, where a value's type depends on which helper touched it last.

And if you're still on Node 22, the clock matters here: it goes end of life on April 30, 2027, while 26 is supported into 2029. You'll make this jump eventually.

The TypeErrors cost me an afternoon of grep. I'd take that over a NaN quietly reordering a billing run.