Replace the separate native-only icu-numberformatter and icu-datetimeformatter
packages with a new universal formatting stack:
packages/ocaml-icu4c- shared native ICU4C foundation
packages/universal-reason-react/intl- JS target with zero-cost bindings to browser/node
Intl - native target backed by
ocaml-icu4c
- JS target with zero-cost bindings to browser/node
- a single public
IntlAPI with:Intl.NumberFormatterIntl.DateTimeFormatter
This is a clean-break migration. The old formatter packages are removed after the new package is wired up.
From either Melange JS or native code, the call shape is the same:
let numberFormatter =
Intl.NumberFormatter.make({
locale: Some("en-US"),
style: Some(Intl.NumberFormatter.Style.Currency),
currency: Some("USD"),
minimumFractionDigits: None,
maximumFractionDigits: None,
useGrouping: None,
});
let price = numberFormatter->Intl.NumberFormatter.format(1234.56);
let dateFormatter =
Intl.DateTimeFormatter.make({
locale: Some("en-US"),
timeZone: Some("UTC"),
dateStyle: Some(Intl.DateTimeFormatter.Style.Short),
timeStyle: None,
weekday: None,
era: None,
year: None,
month: None,
day: None,
hour: None,
minute: None,
second: None,
fractionalSecondDigits: None,
timeZoneName: None,
hour12: None,
hourCycle: None,
});
let date = dateFormatter->Intl.DateTimeFormatter.format(1608434596738.0);The JS target should compile to thin Intl.NumberFormat /
Intl.DateTimeFormat bindings with no runtime platform branching. The native
target should use ICU4C through shared low-level bindings.
Create:
packages/ocaml-icu4c/
dune
src/
dune
discover.ml
icu4c_config.ml (generated)
Icu4c.ml
Icu4c.mli
Icu4c_strings.ml
Icu4c_strings.mli
Icu4c_locale.ml
Icu4c_locale.mli
Icu4c_number.ml
Icu4c_number.mli
Icu4c_datetime.ml
Icu4c_datetime.mli
Recommended dune identity:
name:ocaml_icu4cpublic_name:resync.ocaml_icu4c
This package owns:
- ICU discovery/configuration logic currently duplicated in the formatter packages
- version-suffixed symbol handling
- UTF-8 / UTF-16 conversion helpers
- locale and time-zone normalization helpers
- raw number/date ICU bindings
This package should be wrapped to avoid more module collisions.
Create:
packages/universal-reason-react/intl/
dune
package.json
js/
dune
Intl.re
Intl.rei
native/
dune
Intl.re
Intl.rei
Recommended dune identity:
- JS library
name:universal_reason_react_intl_jspublic_name:resync.universal_reason_react_intl_js
- native library
name:universal_reason_react_intl_nativepublic_name:resync.universal_reason_react_intl_native
- npm package name
@universal-reason-react/intl
This matches the repo's existing universal-reason-react/* split-target layout.
The public boundary should use JS/Intl-style naming so JS and native feel identical.
Expose a single Intl module with two submodules:
Intl.NumberFormatterIntl.DateTimeFormatter
Target shape:
module Intl = {
module NumberFormatter = {
module Style = {
type t = Decimal | Currency | Percent;
};
type part = {
type_: string,
value: string,
};
type t;
let make: (
~locale: string=?,
~style: Style.t=?,
~currency: string=?,
~minimumFractionDigits: int=?,
~maximumFractionDigits: int=?,
~useGrouping: bool=?,
unit,
) => t;
let format: t => float => string;
let formatToParts: t => float => list(part);
let formatWithOptions: (
~locale: string=?,
~style: Style.t=?,
~currency: string=?,
~minimumFractionDigits: int=?,
~maximumFractionDigits: int=?,
~useGrouping: bool=?,
float,
) => string;
let formatToPartsWithOptions: (
~locale: string=?,
~style: Style.t=?,
~currency: string=?,
~minimumFractionDigits: int=?,
~maximumFractionDigits: int=?,
~useGrouping: bool=?,
float,
) => list(part);
};
};Notes:
- Native implementation must add
formatToParts, which does not exist in the currentNumber_formatterpackage. - Keep part shape aligned with JS
Intl.NumberFormat#formatToParts, usingtype_as the Reason-safe field name.
Target shape:
module Intl = {
module DateTimeFormatter = {
module Style = {
type t = Full | Long | Medium | Short;
};
module Text = {
type t = Narrow | Short | Long;
};
module Numeric = {
type t = Numeric | Two_digit;
};
module Month = {
type t = Numeric | Two_digit | Narrow | Short | Long;
};
module HourCycle = {
type t = H11 | H12 | H23 | H24;
};
module TimeZoneName = {
type t =
| Short
| Long
| Short_offset
| Long_offset
| Short_generic
| Long_generic;
};
type part = {
type_: string,
value: string,
};
type t;
let make: (
~locale: string=?,
~timeZone: string=?,
~dateStyle: Style.t=?,
~timeStyle: Style.t=?,
~weekday: Text.t=?,
~era: Text.t=?,
~year: Numeric.t=?,
~month: Month.t=?,
~day: Numeric.t=?,
~hour: Numeric.t=?,
~minute: Numeric.t=?,
~second: Numeric.t=?,
~fractionalSecondDigits: int=?,
~timeZoneName: TimeZoneName.t=?,
~hour12: bool=?,
~hourCycle: HourCycle.t=?,
unit,
) => t;
let format: t => float => string;
let formatToParts: t => float => list(part);
let formatWithOptions: (
~locale: string=?,
~timeZone: string=?,
~dateStyle: Style.t=?,
~timeStyle: Style.t=?,
~weekday: Text.t=?,
~era: Text.t=?,
~year: Numeric.t=?,
~month: Month.t=?,
~day: Numeric.t=?,
~hour: Numeric.t=?,
~minute: Numeric.t=?,
~second: Numeric.t=?,
~fractionalSecondDigits: int=?,
~timeZoneName: TimeZoneName.t=?,
~hour12: bool=?,
~hourCycle: HourCycle.t=?,
float,
) => string;
let formatToPartsWithOptions: (
~locale: string=?,
~timeZone: string=?,
~dateStyle: Style.t=?,
~timeStyle: Style.t=?,
~weekday: Text.t=?,
~era: Text.t=?,
~year: Numeric.t=?,
~month: Month.t=?,
~day: Numeric.t=?,
~hour: Numeric.t=?,
~minute: Numeric.t=?,
~second: Numeric.t=?,
~fractionalSecondDigits: int=?,
~timeZoneName: TimeZoneName.t=?,
~hour12: bool=?,
~hourCycle: HourCycle.t=?,
float,
) => list(part);
};
};Notes:
- The universal input remains
floatepoch milliseconds. - Date-only normalization stays outside this API.
The JS target is a thin wrapper over browser/node Intl.
- bind
Intl.NumberFormat type tshould be the raw JS formatter objectmakeshould compile tonew Intl.NumberFormat(locale, options)formatshould compile to.format(value)formatToPartsshould compile to.formatToParts(value)
- bind
Intl.DateTimeFormat makeshould compile tonew Intl.DateTimeFormat(locale, options)formatshould compile to.format(value)formatToPartsshould compile to.formatToParts(value)
- no
switch%platforminside the universal package - no fallback adapter layer on JS
- no app-specific date normalization inside the core formatter API
- keep the generated JS close to hand-written
Intlcalls
The native target is a high-level wrapper over ocaml-icu4c.
Port logic from current packages/icu-numberformatter, but update it to the new
API shape:
- constructor-style
make - instance-based
format formatWithOptionsformatToParts
Native formatToParts for number formatting requires additional ICU work using
formatted-value APIs. Likely bindings needed:
unumf_resultAsValueufmtval_nextPositionucfpos_openucfpos_closeucfpos_constrainCategory/ucfpos_constrainFieldas needed
Port current packages/icu-datetimeformatter logic into the new API shape:
- move option names to camelCase at the public layer
- keep native normalization and caching internals
- preserve style/component validation rules
- The JS and native
Intlmodules must export the same.reiinterface. - If a feature cannot exist on one side, it should not ship publicly yet.
- Use JS-facing naming at the universal API boundary.
- Keep parity tests for both formatting and
formatToParts.
- Create
packages/ocaml-icu4c. - Move duplicated ICU discovery logic out of the formatter packages.
- Move UTF conversion helpers into shared modules.
- Move raw bindings for number/date formatting into namespaced submodules.
- Build and test the new foundation package on native.
- Add
packages/universal-reason-react/intl/dunewithjs/nativedirs. - Add
package.jsonnamed@universal-reason-react/intl. - Define
Intl.reifirst so both targets conform to the same API. - Implement the JS target using Melange
Intlbindings. - Implement the native target using
ocaml-icu4c.
- Update ecommerce demo wrappers to use the new package.
- Replace direct uses of
Number_formatter/Date_time_formatter. - Update server and client dune dependencies:
- JS depends on
resync.universal_reason_react_intl_js - native depends on
resync.universal_reason_react_intl_native
- JS depends on
- Delete
packages/icu-numberformatter. - Delete
packages/icu-datetimeformatter. - Remove old references from docs and demo code.
- Update package docs to point to
universal-reason-react/intlandocaml-icu4c.
Update:
docs/README.mddocs/API_REFERENCE.md- package-specific docs for the new
intlpackage - package-specific docs for
ocaml-icu4c - any ecommerce demo docs that mention
icu-numberformatter/icu-datetimeformatter
- unit tests for number formatting
- unit tests for number
formatToParts - unit tests for date formatting
- unit tests for date
formatToParts - locale normalization tests
- time-zone normalization tests
- parity-focused tests that run against JS
Intl formatandformatToPartssmoke tests in Node
- compare JS and native outputs for a supported subset:
en-UScurrency/decimal/percent- dateStyle/timeStyle combinations
- common date component combinations
formatToPartspart ordering and part types
- rebuild ecommerce demo client bundle
- rebuild ecommerce server
- verify SSR/client hydration still matches
- verify date and number formatting wrappers remain stable
Not every ICU behavior maps perfectly to browser Intl. The universal package
should initially expose only the subset that is stable across both targets.
This is the main missing feature compared with the JS side and requires new ICU bindings work.
Current native formatter APIs are snake_case. The universal package should move to camelCase for consistency, which means a breaking API change.
The old unwrapped ICU packages already produced collisions. ocaml-icu4c
should be wrapped and namespaced from the start.
This work is complete when:
packages/ocaml-icu4cexists and is the only native ICU binding packagepackages/universal-reason-react/intlexists with JS and native targets- JS and native export the same
IntlAPI Intl.NumberFormatterandIntl.DateTimeFormatterboth support:makeformatformatToParts
- ecommerce demo uses the universal package instead of direct JS/native wrappers
- old formatter packages are removed
- docs are updated
- targeted build and parity tests pass
- Build
ocaml-icu4cfirst. - Move native datetime logic over.
- Move native number logic over and add
formatToParts. - Add JS universal bindings.
- Lock the shared
.reisurface. - Migrate ecommerce demo.
- Remove old packages.
- Update docs and examples.