- Book: PHP to TypeScript
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You write new Date(2026, 9, 25) for September 25 in your first Node service and get October 25, because the Date constructor counts months from zero. Then you add a day by calling setDate(), which changes the object in place, in whatever time zone the server happens to run in. If you came from PHP with Carbon in your fingers, this is the moment you start missing it.
You can stop missing it. Temporal reached Stage 4 at TC39's 113th plenary in March 2026, and Node.js 26.0.0, released on 2026-05-05, turns it on by default. No flag, no polyfill. Node 26 is still the "Current" line today, and the Node.js release schedule plans its move to Active LTS for 2026-10-28, a common point for teams to upgrade.
Almost every Carbon habit has a direct Temporal twin. Two of your reflexes don't, and they get their own section at the end. Below, each PHP snippet sits next to the TypeScript that does the same job. Run the PHP with php and the TypeScript with node.
Setup: Node 26 and a small tsconfig
Node 26 runs .ts files directly. Type stripping has been on by default since v23.6.0 and stable since v25.2.0, so node dates.ts works as long as you stick to erasable syntax (no enum, no parameter properties). Node does no type checking at run time; your editor and tsc do.
For the types, TypeScript 6.0 ships built-in Temporal declarations. Enable them with esnext or the narrower esnext.temporal lib. The same release changes the default types to [], so @types/node (and with it console) is no longer picked up on its own. Install it with npm i -D @types/node and list it:
{
"compilerOptions": {
"target": "es2024",
"lib": ["es2024", "esnext.temporal"],
"types": ["node"],
"module": "nodenext",
"noEmit": true,
"erasableSyntaxOnly": true
}
}
erasableSyntaxOnly makes tsc reject the syntax Node can't strip, so the "no enum" rule is enforced for you.
On the PHP side, the snippets use the built-in DateTimeImmutable and Carbon 3 (composer require nesbot/carbon). If you are still on Node 24, the @js-temporal/polyfill package provides the same API, with two caveats: it does not install a global, so add import { Temporal } from '@js-temporal/polyfill'; at the top of each file, and its README still marks it as not yet production-ready.
PlainDate: the type PHP never gave you
PHP has no date-only type. new DateTimeImmutable('2026-10-15') is midnight in your default time zone, and Carbon extends DateTime, so it carries the same baggage. Temporal splits the concepts: PlainDate is a calendar date with no time and no zone, which is what a birthday, an invoice due date or a holiday actually is.
<?php
$d = new DateTimeImmutable('2026-10-15');
$next = $d->modify('+1 day');
echo $d->format('Y-m-d'), ' ', $next->format('Y-m-d'), PHP_EOL;
// 2026-10-15 2026-10-16
const d = Temporal.PlainDate.from('2026-10-15');
const next = d.add({ days: 1 });
console.log(d.toString(), next.toString());
// 2026-10-15 2026-10-16
Month ends are where the two part ways. PHP lets day numbers overflow into the next month: "2008-06-31" means July 1. So January 31 plus one month becomes February 31, which rolls over to March 3 in 2026. Temporal's default is to clamp to the last valid day instead.
<?php
$jan = new DateTimeImmutable('2026-01-31');
echo $jan->modify('+1 month')->format('Y-m-d'), PHP_EOL;
// 2026-03-03
const jan = Temporal.PlainDate.from('2026-01-31');
console.log(jan.add({ months: 1 }).toString());
// 2026-02-28
Pass { overflow: 'reject' } as the second argument and Temporal throws a RangeError rather than clamping. from() takes the same option, and that is the one you want for anything a user typed.
Parsing is the other big difference. createFromFormat() has no Temporal twin: PlainDate.from() takes RFC 9557 strings (ISO-style 2026-10-15) or an object. For a d/m/Y string, split it yourself and hand Temporal the parts:
<?php
$utc = new DateTimeZone('UTC');
$d = DateTimeImmutable::createFromFormat(
'!d/m/Y', '15/10/2026', $utc
);
echo $d->format('Y-m-d'), PHP_EOL; // 2026-10-15
function fromDmy(s: string): Temporal.PlainDate {
const [day, month, year] = s.split('/').map(Number);
return Temporal.PlainDate.from(
{ year, month, day },
{ overflow: 'reject' },
);
}
console.log(fromDmy('15/10/2026').toString()); // 2026-10-15
Note the ! in the PHP format. Without it, every field you don't parse is filled from the current system time, so your "date" quietly carries the time the request ran. A PlainDate has no time to leak.

PlainDate is a date with the clock and the time zone removed.
ZonedDateTime and the 25-hour Sunday
In 2026, Europe falls back on Sunday, October 25, and the US on Sunday, November 1. That makes October 25 in Berlin a 25-hour day, and it is the classic place where "add one day" and "add 24 hours" stop meaning the same thing.
PHP has both behaviours, spread across two methods that look alike: modify('+1 day') keeps the wall-clock time, while add(new DateInterval('PT24H')) adds elapsed time. The PHP manual's arithmetic examples walk through the same split between modify() and add():
<?php
$tz = new DateTimeZone('Europe/Berlin');
$sat = new DateTimeImmutable('2026-10-24 12:00', $tz);
echo $sat->modify('+1 day')->format('c'), PHP_EOL;
// 2026-10-25T12:00:00+01:00
echo $sat->add(new DateInterval('PT24H'))->format('c'), PHP_EOL;
// 2026-10-25T11:00:00+01:00
Temporal has one rule, and it is easy to remember. Days use calendar arithmetic and keep the clock time; hours use exact elapsed time:
const sat = Temporal.ZonedDateTime.from(
'2026-10-24T12:00[Europe/Berlin]',
);
const sun = sat.add({ days: 1 });
console.log(sun.toString());
// 2026-10-25T12:00:00+01:00[Europe/Berlin]
console.log(sat.add({ hours: 24 }).toString());
// 2026-10-25T11:00:00+01:00[Europe/Berlin]
console.log(sun.hoursInDay); // 25
The zone lives inside the value as a bracketed IANA name, so the result never depends on what TZ the container was started with. hoursInDay is a nice bonus for billing or shift code that needs to know how long "today" is.
Time zone conversion is setTimezone() in PHP and withTimeZone() in Temporal. Both keep the instant and change the zone:
<?php
$berlin = new DateTimeImmutable(
'2026-10-15 18:00', new DateTimeZone('Europe/Berlin')
);
$ny = $berlin->setTimezone(new DateTimeZone('America/New_York'));
echo $ny->format('c'), PHP_EOL;
// 2026-10-15T12:00:00-04:00
const berlin = Temporal.ZonedDateTime.from(
'2026-10-15T18:00[Europe/Berlin]',
);
const ny = berlin.withTimeZone('America/New_York');
console.log(ny.toString());
// 2026-10-15T12:00:00-04:00[America/New_York]
const later = berlin.add({ days: 11 });
console.log(later.withTimeZone('America/New_York').toString());
// 2026-10-26T13:00:00-04:00[America/New_York]
The second result is the week between the two fall-backs: Berlin is already on winter time, New York is not, so your 18:00 call lands at 13:00 instead of 12:00. Temporal gets that right because it keeps the real zone, not a fixed offset.
Durations and diffInDays
PHP's DateInterval and Temporal's Duration read the same ISO 8601 strings, so PT1H30M means the same thing in both. The difference is that Duration can total itself:
<?php
$i = new DateInterval('PT1H30M');
echo $i->h * 60 + $i->i, PHP_EOL; // 90
const dur = Temporal.Duration.from('PT1H30M');
console.log(dur.total({ unit: 'minutes' })); // 90
For "how many days between", PHP gives you DateInterval::$days, which is non-negative, with the direction in $invert. Carbon 3 changed its version: diffIn* methods now return a float and are signed by default ($absolute = false). If your code was written against Carbon 2, that is worth a grep.
<?php
require 'vendor/autoload.php';
use Carbon\CarbonImmutable;
$a = CarbonImmutable::parse('2026-10-15', 'UTC');
$b = CarbonImmutable::parse('2026-12-25', 'UTC');
var_dump($a->diffInDays($b)); // float(71)
var_dump($b->diffInDays($a)); // float(-71)
Temporal's until() and since() return a Duration, signed, and for a PlainDate the largest unit defaults to days:
const a = Temporal.PlainDate.from('2026-10-15');
const b = Temporal.PlainDate.from('2026-12-25');
console.log(a.until(b).days); // 71
console.log(b.until(a).days); // -71
const parts = a.until(b, { largestUnit: 'months' });
console.log(parts.toString()); // P2M10D
Integer days, no float to cast, and no time zone to pin to UTC first, because a PlainDate doesn't have one.
The two PHP habits that bite
Habit one: expecting a call to change the object. Carbon is mutable. A Carbon modifier changes and returns the same instance, which is the source of the classic "why did my start date move" bug:
<?php
require 'vendor/autoload.php';
use Carbon\Carbon;
$start = Carbon::parse('2026-10-15');
$end = $start->addDays(7);
echo $start->toDateString(), PHP_EOL; // 2026-10-22
var_dump($start === $end); // bool(true)
Every Temporal type is immutable, so the bug flips direction. If you call add() as a statement, the way you would on mutable Carbon, the result goes nowhere and nothing changes. TypeScript won't warn you about a discarded return value:
const start = Temporal.PlainDate.from('2026-10-15');
start.add({ days: 7 }); // result thrown away
console.log(start.toString()); // 2026-10-15
const end = start.add({ days: 7 });
console.log(end.toString()); // 2026-10-22
console.log(start.with({ day: 1 }).toString()); // 2026-10-01
There are no setters. with() is how you "set" a field, and it returns a new value too. If you already use CarbonImmutable or DateTimeImmutable, you have the right reflex: always assign the result.
Habit two: comparing with operators. PHP compares DateTime objects by value with ==, < and >, and === checks identity:
<?php
$a = new DateTimeImmutable('2026-10-25');
$b = new DateTimeImmutable('2026-10-25');
var_dump($a == $b); // bool(true)
var_dump($a === $b); // bool(false)
var_dump($a < $b); // bool(false)
JavaScript has no value-equality operator for objects. === is identity, so two equal dates built separately are not ===. And < doesn't silently compare strings: Temporal's valueOf() always throws a TypeError so the mistake fails loudly.
const a = Temporal.PlainDate.from('2026-10-25');
const b = Temporal.PlainDate.from('2026-10-25');
console.log(a === b); // false: two objects
console.log(a.equals(b)); // true
console.log(Temporal.PlainDate.compare(a, b)); // 0
// a < b throws TypeError: valueOf() always throws
const dates = [b, Temporal.PlainDate.from('2026-01-01')];
dates.sort(Temporal.PlainDate.compare);
const unique = new Set(dates.map(String));
console.log([...unique]); // [ '2026-01-01', '2026-10-25' ]
compare slots straight into sort(). For Set or Map keys, convert to strings first, because the Set holds objects by identity.

Two equal values, two separate objects: === sees the objects, equals() sees the values.
One trap inside the trap: for ZonedDateTime, equals() checks the instant, the time zone and the calendar, while ZonedDateTime.compare() looks only at the instant. The same moment in Berlin and New York is compare() === 0 but not equals(). In practice, PHP's == on two zoned DateTime values compares the instant, like compare(), so when you port an equality check, decide which question you were asking.
If this was useful
The date API is a small slice of the PHP-to-TypeScript move. The bigger shifts (sync to async, structural typing, unions where you used to reach for class hierarchies) are what PHP to TypeScript is about, written for PHP 8+ developers who already know how to build things and want the mapping, not a beginner course.
It is book 4 of The TypeScript Library. If you came from PHP, start with PHP to TypeScript; add The TypeScript Type System once the basics stick, and TypeScript in Production when you are shipping at work:
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types
- Kotlin and Java to TypeScript — JVM developers moving to TypeScript
- PHP to TypeScript — PHP 8+ developers moving to TypeScript
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR
All five books ship in ebook, paperback, and hardcover.

Top comments (0)