Skip to content

Add Temporal bindings to the standard library - #9094

Open
mununki wants to merge 3 commits into
masterfrom
temporal-bindings
Open

mununki wants to merge 3 commits into
masterfrom
temporal-bindings

Conversation

@mununki

@mununki mununki commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Implements the Temporal bindings proposed in #9044. They are externals that compile to direct calls on the global Temporal, so they need a runtime that provides it, such as Node.js 26, or a polyfill that defines globalThis.Temporal.

let meeting = Temporal.ZonedDateTime.fromStringOrThrow(
  "2024-03-09T09:30:00-05:00[America/New_York]",
)
let nextMeeting = meeting->Temporal.ZonedDateTime.addFields({days: 1.}) // JS: meeting.add({ days: 1 })

nextMeeting->Temporal.ZonedDateTime.toString
// "2024-03-10T09:30:00-04:00[America/New_York]"

// The day daylight saving time starts is 23 hours long
meeting->Temporal.ZonedDateTime.until(nextMeeting)->Temporal.Duration.toString
// "PT23H"

The PR has three commits:

  1. Treat Temporal as a JS global. A binding named Temporal is printed as $$Temporal, like one named after another standard built-in such as Intl. Without this, a module named Temporal would hide the global from the bindings: they are externals with dotted names like "Temporal.PlainDate.from", which, unlike @scope externals (Read JS globals through globalThis wherever a same-named binding is in scope #8785), aren't read through globalThis. Raw JavaScript next to such a binding reads the global as well.
  2. Add Temporal bindings to the standard library. The bindings, their tests and the changelog.
  3. Map Temporal types to TypeScript in GenType. The types map to TypeScript's Temporal.* types, which need TypeScript 6.0 or later and its esnext.temporal lib (included in ESNext).

The design is the one in #9044:

  • Modules. Temporal.Duration, Instant, PlainDate, PlainTime, PlainDateTime, PlainYearMonth, PlainMonthDay, ZonedDateTime and Now, one file each, aliased from Stdlib_Temporal.res like Intl. The value types are defined once in Temporal.Common, so the modules convert into each other without a dependency cycle, and @editor.completeFrom keeps pipe completion working on them. Date.toTemporalInstant converts a Date.
  • Overloads are separate functions that compile to the same JS call: make, fromStringOrThrow and fromString, fromFields, add and addFields, Duration.compareWithOptions.
  • Options are an optional ~options argument. Options that JS requires, like those of round and Duration.total, are a positional argument.
  • Types. Calendars and units are polymorphic variants, time zones and eras are strings. Duration fields are float, date and time fields int, and epochNanoseconds is bigint. Nullable results are options.
  • Errors pass through, except that fromString returns None for a RangeError.

Changes since the RFC, following the review there:

  • == is unchanged. ReScript's ==, compare, < and > treat any two Temporal values as equal, so each module's docs say to compare with its equals and compare. JS has no Duration.prototype.equals: the Duration docs point to compare and compareWithOptions, and note that P1D and PT24H compare as equal. There is no field-wise Duration.equal; adding one later wouldn't break anything.
  • The tests run in CI on Node.js 26 (Develop and test with Node.js 26 #9059). Stdlib_TemporalTests is its own entry point, run by scripts/test.js, because the installation tests run Stdlib_TestSuite on Node.js 22. On a Node.js without Temporal, scripts/test.js skips it with a warning, as DocTest.res does for doc examples.

Breaking changes:

  • The stdlib Temporal module shadows a project's own top-level Temporal module, such as bindings to the Temporal.io SDK.
  • A nested module named Temporal is exported to JS as $$Temporal.

Tests: 127 doc examples, 27 tests in Stdlib_TemporalTests, the completion test CompletionTemporal.res, and a gentype test in stdlib-no-shims. DocTest.res now emits the doc examples it skips on older Node.js versions as test.skip instead of dropping them, so they are still type checked there.

Not included, as listed in #9044: Duration.toLocaleString options, largestUnit: "auto", relativeTo as a PlainDateTime or a string, string arguments to add, subtract and compare, from with another Temporal value, plural unit names, Intl.DateTimeFormat.format with Temporal values, and valueOf.

Closes #9044.

Checklist

  • Added a changelog fragment (see changes/README.md), or no release notes are needed and a maintainer should apply changelog:skip.
  • Added tests for the fix or feature.
  • Signed off every commit for the DCO (git commit -s).

Why:
    A binding printed as `Temporal` hides the global `Temporal` in its
    scope. The bindings of the next commit are externals with dotted names
    like "Temporal.PlainDate.from", which are printed as written: unlike
    `@scope` externals (#8785), they are not read through `globalThis`
    when a binding of the same name is in scope. A module named `Temporal`
    next to a call of the bindings would then be called instead.

JS modified (module named Temporal):
Example:
    type instant
    @Val external now: unit => instant = "Temporal.Now.instant"

    module Temporal = {
      let zone = "UTC"
    }

    let stamp = () => now()
Before (js):
    let Temporal = {
      zone: "UTC"
    };

    function stamp() {
      return Temporal.Now.instant();   // the module: a TypeError
    }

    export {
      Temporal,
      stamp,
    }
After (js):
    let $$Temporal = {
      zone: "UTC"
    };

    function stamp() {
      return Temporal.Now.instant();   // the global
    }

    export {
      $$Temporal,
      stamp,
    }

Review: Why not read dotted externals through `globalThis` instead? That
    changes every dotted external, not only Temporal's. A reserved name is
    how `Intl` and the other built-ins are handled, and it also keeps raw
    JavaScript next to the module reading the global. The next commit
    tests it in `Stdlib_TemporalTests`.

Breaking: A nested module named `Temporal` is exported to JS as
    `$$Temporal`, like one named `Intl`. JavaScript that imports it by name
    imports `$$Temporal`.

Signed-off-by: mununki <woonki.moon@gmail.com>
Why:
    JavaScript's Temporal API covers dates, times, time zones and
    durations, which `Date` handles poorly, and Node.js 26 ships it. #9044
    proposed stdlib bindings for it.

JS added (Temporal bindings):
Example:
    let meeting = Temporal.ZonedDateTime.fromStringOrThrow(
      "2024-03-09T09:30:00-05:00[America/New_York]",
    )
    let nextMeeting = meeting->Temporal.ZonedDateTime.addFields({days: 1.})
    let length = meeting->Temporal.ZonedDateTime.until(nextMeeting)

    let birthday = Temporal.PlainMonthDay.fromString("02-29")
    let started = Date.make()->Date.toTemporalInstant
After (js):
    import * as Stdlib_Temporal_PlainMonthDay from "@rescript/runtime/lib/es6/Stdlib_Temporal_PlainMonthDay.mjs";

    let meeting = Temporal.ZonedDateTime.from("2024-03-09T09:30:00-05:00[America/New_York]");

    let nextMeeting = meeting.add({
      days: 1
    });

    let length = meeting.until(nextMeeting);

    let birthday = Stdlib_Temporal_PlainMonthDay.fromString("02-29");

    let started = new Date().toTemporalInstant();

Review: Why do `==` and `compare` treat any two Temporal values as equal?
    Temporal objects keep their state in internal slots and have no
    enumerable properties to compare. Each module's docs point to its
    `equals` and `compare`; #9044 keeps `==` as it is.
Review: Why does `fromString` rethrow errors other than `RangeError`? Only
    a `RangeError` means the string is invalid. Without the `Temporal`
    global the call throws a `ReferenceError`, which `None` would hide.
Review: Why are the tests not in `Stdlib_TestSuite`? The installation
    tests run that suite on Node.js 22, which lacks Temporal.
    `Stdlib_TemporalTests` is its own entry point, which `scripts/test.js`
    runs when `globalThis.Temporal` exists. On older Node.js versions,
    `DocTest.res` now emits the doc examples it skips as `test.skip`, so
    they are still type checked.

Breaking: The stdlib `Temporal` module shadows a project's own top-level
    `Temporal` module, such as bindings to the Temporal.io SDK. Rename such
    a module.

Signed-off-by: mununki <woonki.moon@gmail.com>
Why:
    GenType emitted the Temporal types as types imported from `Stdlib.gen`
    and `Stdlib_Temporal_Common.gen`, files that don't exist, so the
    generated TypeScript didn't compile. TypeScript 6.0 declares the same
    types in its `esnext.temporal` lib, which `ESNext` includes.

Representation modified (genType's TypeScript type of Temporal values):
Example:
    @Gentype
    let temporalDateOfDateTime = (x: Temporal.PlainDateTime.t) => x->Temporal.PlainDateTime.toPlainDate
    @Gentype
    let temporalNextDay = (x: Temporal.PlainDate.t) => x->Temporal.PlainDate.addFields({days: 1.})
Before (ts): tests/gentype_tests/stdlib-no-shims/src/StdlibNoShims.gen.tsx
    import type {Temporal_PlainDateTime_t as Stdlib_Temporal_PlainDateTime_t} from './Stdlib.gen';
    import type {Temporal_PlainDate_t as Stdlib_Temporal_PlainDate_t} from './Stdlib.gen';
    import type {plainDate as Stdlib_Temporal_Common_plainDate} from './Stdlib_Temporal_Common.gen';

    export const temporalDateOfDateTime: (x:Stdlib_Temporal_PlainDateTime_t) => Stdlib_Temporal_Common_plainDate = StdlibNoShimsJS.temporalDateOfDateTime as any;

    export const temporalNextDay: (x:Stdlib_Temporal_PlainDate_t) => Stdlib_Temporal_PlainDate_t = StdlibNoShimsJS.temporalNextDay as any;
After (ts): tests/gentype_tests/stdlib-no-shims/src/StdlibNoShims.gen.tsx
    export const temporalDateOfDateTime: (x:Temporal.PlainDateTime) => Temporal.PlainDate = StdlibNoShimsJS.temporalDateOfDateTime as any;

    export const temporalNextDay: (x:Temporal.PlainDate) => Temporal.PlainDate = StdlibNoShimsJS.temporalNextDay as any;

Review: Why match the types of `Temporal.Common` too? Conversions like
    `PlainDateTime.toPlainDate` return the shared types of
    `Stdlib_Temporal_Common`, which the module types are defined as.

Signed-off-by: mununki <woonki.moon@gmail.com>
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Changelog

💥 Breaking Change

  • The new Temporal stdlib module shadows a project module named Temporal, so rename such a module. A nested module named Temporal is now exported to JS as $$Temporal, like modules named after other JS globals such as Intl, so that it no longer shadows the global Temporal.

🚀 New Feature

  • Add Temporal bindings to the standard library, covering durations, instants, plain and zoned dates and times, and Temporal.Now, plus Date.toTemporalInstant. They need a runtime that provides Temporal, such as Node.js 26. Compare Temporal values with the equals and compare functions of their module, since ==, compare, < and > treat any two of them as equal. GenType maps Temporal types to TypeScript's Temporal.* types, which need TypeScript 6.0 or later and its esnext.temporal lib (included in ESNext).

Semantic Diff

3 commits · Full page

✏️ Treat Temporal as a JS global

ad770f4 · mununki · Oct 27, 2026, 23:27 UTC · edited

Why

A binding printed as Temporal hides the global Temporal in its scope. The bindings of the next commit are externals with dotted names like "Temporal.PlainDate.from", which are printed as written: unlike @scope externals (#8785), they are not read through globalThis when a binding of the same name is in scope. A module named Temporal next to a call of the bindings would then be called instead.

JS modified (module named Temporal)

type instant
@val external now: unit => instant = "Temporal.Now.instant"

module Temporal = {
  let zone = "UTC"
}

let stamp = () => now()

js

-let Temporal = {
+let $$Temporal = {
   zone: "UTC"
 };
 
 function stamp() {
-  return Temporal.Now.instant();   // the module: a TypeError
+  return Temporal.Now.instant();   // the global
 }
 
 export {
-  Temporal,
+  $$Temporal,
   stamp,
 }

⚠️ Breaking: A nested module named Temporal is exported to JS as $$Temporal, like one named Intl. JavaScript that imports it by name imports $$Temporal.

Review

  • Why not read dotted externals through globalThis instead? That changes every dotted external, not only Temporal's. A reserved name is how Intl and the other built-ins are handled, and it also keeps raw JavaScript next to the module reading the global. The next commit tests it in Stdlib_TemporalTests.
✏️ Add Temporal bindings to the standard library

e2d13dd · mununki · Oct 27, 2026, 23:27 UTC · edited

Why

JavaScript's Temporal API covers dates, times, time zones and durations, which Date handles poorly, and Node.js 26 ships it. #9044 proposed stdlib bindings for it.

JS added (Temporal bindings)

let meeting = Temporal.ZonedDateTime.fromStringOrThrow(
  "2024-03-09T09:30:00-05:00[America/New_York]",
)
let nextMeeting = meeting->Temporal.ZonedDateTime.addFields({days: 1.})
let length = meeting->Temporal.ZonedDateTime.until(nextMeeting)

let birthday = Temporal.PlainMonthDay.fromString("02-29")
let started = Date.make()->Date.toTemporalInstant

js · After

import * as Stdlib_Temporal_PlainMonthDay from "@rescript/runtime/lib/es6/Stdlib_Temporal_PlainMonthDay.mjs";

let meeting = Temporal.ZonedDateTime.from("2024-03-09T09:30:00-05:00[America/New_York]");

let nextMeeting = meeting.add({
  days: 1
});

let length = meeting.until(nextMeeting);

let birthday = Stdlib_Temporal_PlainMonthDay.fromString("02-29");

let started = new Date().toTemporalInstant();

⚠️ Breaking: The stdlib Temporal module shadows a project's own top-level Temporal module, such as bindings to the Temporal.io SDK. Rename such a module.

Review

  • Why do == and compare treat any two Temporal values as equal? Temporal objects keep their state in internal slots and have no enumerable properties to compare. Each module's docs point to its equals and compare; RFC: Temporal API bindings #9044 keeps == as it is.
  • Why does fromString rethrow errors other than RangeError? Only a RangeError means the string is invalid. Without the Temporal global the call throws a ReferenceError, which None would hide.
  • Why are the tests not in Stdlib_TestSuite? The installation tests run that suite on Node.js 22, which lacks Temporal. Stdlib_TemporalTests is its own entry point, which scripts/test.js runs when globalThis.Temporal exists. On older Node.js versions, DocTest.res now emits the doc examples it skips as test.skip, so they are still type checked.
✏️ Map Temporal types to TypeScript in GenType

a56765f · mununki · Oct 27, 2026, 23:27 UTC · edited

Why

GenType emitted the Temporal types as types imported from Stdlib.gen and Stdlib_Temporal_Common.gen, files that don't exist, so the generated TypeScript didn't compile. TypeScript 6.0 declares the same types in its esnext.temporal lib, which ESNext includes.

Representation modified (genType's TypeScript type of Temporal values)

@genType
let temporalDateOfDateTime = (x: Temporal.PlainDateTime.t) => x->Temporal.PlainDateTime.toPlainDate
@genType
let temporalNextDay = (x: Temporal.PlainDate.t) => x->Temporal.PlainDate.addFields({days: 1.})

ts · tests/gentype_tests/stdlib-no-shims/src/StdlibNoShims.gen.tsx

-import type {Temporal_PlainDateTime_t as Stdlib_Temporal_PlainDateTime_t} from './Stdlib.gen';
-import type {Temporal_PlainDate_t as Stdlib_Temporal_PlainDate_t} from './Stdlib.gen';
-import type {plainDate as Stdlib_Temporal_Common_plainDate} from './Stdlib_Temporal_Common.gen';
-
-export const temporalDateOfDateTime: (x:Stdlib_Temporal_PlainDateTime_t) => Stdlib_Temporal_Common_plainDate = StdlibNoShimsJS.temporalDateOfDateTime as any;
+export const temporalDateOfDateTime: (x:Temporal.PlainDateTime) => Temporal.PlainDate = StdlibNoShimsJS.temporalDateOfDateTime as any;
 
-export const temporalNextDay: (x:Stdlib_Temporal_PlainDate_t) => Stdlib_Temporal_PlainDate_t = StdlibNoShimsJS.temporalNextDay as any;
+export const temporalNextDay: (x:Temporal.PlainDate) => Temporal.PlainDate = StdlibNoShimsJS.temporalNextDay as any;

Review

  • Why match the types of Temporal.Common too? Conversions like PlainDateTime.toPlainDate return the shared types of Stdlib_Temporal_Common, which the module types are defined as.

@codecov

codecov Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.90909% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 81.46%. Comparing base (1a49d91) to head (a56765f).
⚠️ Report is 8 commits behind head on master.

Files with missing lines Patch % Lines
compiler/gentype/translate_type_expr_from_types.ml 90.90% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #9094      +/-   ##
==========================================
+ Coverage   81.42%   81.46%   +0.03%     
==========================================
  Files         459      460       +1     
  Lines       64547    64675     +128     
==========================================
+ Hits        52559    52689     +130     
+ Misses      11988    11986       -2     
Files with missing lines Coverage Δ
compiler/ext/js_reserved_map.ml 100.00% <ø> (ø)
compiler/gentype/translate_type_expr_from_types.ml 88.88% <90.90%> (+0.08%) ⬆️

... and 44 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pkg-pr-new

pkg-pr-new Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript@9094

@rescript/belt

npm i https://pkg.pr.new/@rescript/belt@9094

@rescript/darwin-arm64

npm i https://pkg.pr.new/@rescript/darwin-arm64@9094

@rescript/linux-arm64

npm i https://pkg.pr.new/@rescript/linux-arm64@9094

@rescript/linux-x64

npm i https://pkg.pr.new/@rescript/linux-x64@9094

@rescript/runtime

npm i https://pkg.pr.new/@rescript/runtime@9094

@rescript/win32-x64

npm i https://pkg.pr.new/@rescript/win32-x64@9094

commit: a56765f

@github-actions

Copy link
Copy Markdown

@mununki
mununki marked this pull request as ready for review October 28, 2026 00:25
@mununki

mununki commented Oct 28, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-28T11:50:04.423369Z a56765f Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: 1c270f94ef

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@mununki
mununki requested review from JonoPrest, cknitt and shulhi and removed request for JonoPrest and cknitt October 28, 2026 00:29
@cknitt

cknitt commented Oct 28, 2026

Copy link
Copy Markdown
Member

@mununki Looks great! Could you just ask you agent to fill out the commit records for semantic diff using the records skill as described in AGENTS.md?

@mununki

mununki commented Oct 28, 2026

Copy link
Copy Markdown
Member Author

@codex review

@mununki

mununki commented Oct 28, 2026

Copy link
Copy Markdown
Member Author

Could you just ask you agent to fill out the commit records for semantic diff using the records skill as described in AGENTS.md?

Done, thanks! All three commits now have records, and the Semantic Diff shows them, with the two breaking changes as Breaking: sections.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: a56765f270

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@cknitt

cknitt commented Oct 28, 2026

Copy link
Copy Markdown
Member

Great, thanks a lot!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RFC: Temporal API bindings

2 participants