Skip to content

feat!: parse dates to native Date instead of Moment - #275

Merged
joris974 merged 1 commit into
mainfrom
drop-moment-timezone
Aug 25, 2026
Merged

feat!: parse dates to native Date instead of Moment#275
joris974 merged 1 commit into
mainfrom
drop-moment-timezone

Conversation

@joris974

@joris974 joris974 commented Aug 25, 2026

Copy link
Copy Markdown
Member

Summary

date() was the only thing in this repo importing moment-timezone, and it used none of the timezone functionality — just moment(x) followed by isValid(). That pulled the whole IANA timezone database into every consumer's bundle to do a string parse.

bundled, minified
moment-timezone 782,814 bytes
date-fns (parseISO + isValid) 3,062 bytes

date() now returns a native Date. The library's public API carries no date-library opinion, so consumers pick whatever they use downstream. moment is now entirely absent from pnpm-lock.yaml — it was the only path to it.

Upstream status

Moment is also a dead end for us going forward. Its own README says so:

⚠️ Moment.js is a legacy project, now in maintenance mode. New features are not being accepted.

Moment core's last release was 2.30.1, in December 2023 — over two and a half years ago. moment-timezone still publishes (0.6.3 landed 2026-07-19), but those releases are IANA timezone database refreshes rather than library work, which is exactly what maintenance mode looks like. The upstream project also recommends migrating to a modern alternative, date-fns among them.

So the 764KB we were shipping bought us a dependency that is explicitly not being developed.

Why date-fns rather than bare new Date()

new Date() is unsafe for a validation library. Measured against the current behavior:

input moment (before) new Date() parseISO (after)
'2016-03-12 13:00:00' 21:00Z 21:00Z 21:00Z ✅
'2016-03-12' (date-only) local midnight UTC midnight ⚠️ local midnight ✅
'2016-02-30' rejected rolls to Mar 1 ⚠️ rejected ✅
'2015-02-29' rejected rolls to Mar 1 ⚠️ rejected ✅

parseISO matches the previous moment behavior on every case that matters. date-fns is also already used in megarepo, so this doesn't introduce a new library to the stack.

Temporal would be the longer-term answer, but it's unavailable on Node 22 (typeof Temporal === 'undefined') and would need a polyfill.

Release

This is a major version bump: 3.0.1 -> 4.0.0.

The commit is feat!: with a BREAKING CHANGE: footer, so semantic-release cuts the major automatically on merge. Note that package.json in the repo reads "version": "2.0.0" — that value is stale and unmanaged, because @semantic-release/npm sets the version at publish time and does not commit it back. The published latest is 3.0.1 (tag v3.0.1), so don't read the 2.0.0 in the diff as the current version, and there's no manual version edit in this PR.

Worth knowing for anyone tracking the v3 references below: v3.0.0 was the semantic-release adoption, not an API change. date() still returns ParserT<Moment> in the published v3.0.1, so the console comment predicting nativeDate() "in v3" was overtaken by events — that change is landing here, in v4.

Breaking changes

  1. date() returns ParserT<Date>, not ParserT<Moment>.
  2. Only ISO-8601 is accepted. '03/12/2016' and 'March 12, 2016' previously worked via moment's deprecated Date fallback (it logged a deprecation warning) and are now rejected. '03/12/2016' is genuinely ambiguous between March 12 and December 3.

date() returned a Moment through v3. To keep Moment values, convert at the boundary:

mapStatic(date(), d => moment(d))

Downstream impact

megarepo is the only consumer. frontend/platform/console already has a Moment→Date adapter used across ~15 model files, carrying this note:

REMOVE when @freckle/parser v3 adds nativeDate().

That adapter collapses to date(). frontend/educator (PremiumCutoff = Moment) and frontend/student's test mock need the inverse wrapper above; megarepo has 409 files importing moment-timezone, so it stays moment-based regardless. A companion megarepo PR is required before this is consumed — not included here.

Notes for the reviewer

  • parseISO accepts the ISO century form, so '12' parses as year 1200 (moment gave 2001-12-01). Both are nonsense; I kept parseISO's standard-conformant behavior rather than adding custom length rules. Flagging in case you'd rather guard it.
  • New tests use for...of rather than lodash forEach to avoid conflicting with refactor: remove lodash dependency #273, which removes lodash from this file.
  • Verified under TZ=UTC, Asia/Tokyo, America/Los_Angeles, Pacific/Kiritimati — the date assertions are timezone-independent.

Test plan

  • pnpm test — 82 tests pass (76 before, +6 pinning the new contract)
  • pnpm build — typechecks clean; dist/index.d.ts emits date(): ParserT<Date>; no Moment reference left in dist/
  • pnpm check-git-clean — committed dist/ in sync
  • prettier --check 'src/**/*.ts' — clean
  • grep -c moment pnpm-lock.yaml0

🤖 Generated with Claude Code

@joris974
joris974 requested a review from a team as a code owner August 25, 2026 17:12
@joris974
joris974 requested review from z0isch and removed request for a team August 25, 2026 17:12
@github-actions

Copy link
Copy Markdown

PR checklist

  • Version in package.json is updated according to semver policy.
  • Built dist/ reflects all changes in PR prior to merge.

@joris974
joris974 marked this pull request as draft August 25, 2026 17:13
@joris974
joris974 removed the request for review from z0isch August 25, 2026 17:13
@joris974 joris974 self-assigned this Aug 25, 2026
@joris974
joris974 marked this pull request as ready for review August 25, 2026 17:17
@joris974
joris974 requested a review from cdmren August 25, 2026 17:17
`date()` was the only consumer of moment-timezone in this repo, and it used
none of the timezone functionality — just `moment(x)` and `isValid()`. That
pulled the entire IANA timezone database into every consumer's bundle: 764KB
minified, against 3KB for date-fns' `parseISO` + `isValid`.

Parsing now returns a native `Date`, so the library's public API carries no
date-library opinion and consumers can pick whatever they use downstream.
date-fns is preferred over bare `new Date()` because `new Date()` is unsafe
for validation — it silently rolls `'2016-02-30'` over to March 1, and
resolves date-only strings to UTC rather than local midnight. `parseISO`
matches the previous moment behavior on both.

Only ISO-8601 is accepted now. Locale-dependent formats that moment took via
its deprecated `Date` fallback (`'03/12/2016'`, `'March 12, 2016'`) are
rejected, since `'03/12/2016'` is ambiguous between March 12 and December 3.

BREAKING CHANGE: `date()` returns `ParserT<Date>` instead of `ParserT<Moment>`,
and rejects non-ISO-8601 strings that were previously accepted. To keep Moment
values, convert at the boundary with `mapStatic(date(), d => moment(d))`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@joris974
joris974 force-pushed the drop-moment-timezone branch from 0976ee5 to fe61bda Compare August 25, 2026 17:18
@joris974
joris974 merged commit eda9cd8 into main Aug 25, 2026
5 checks passed
@joris974
joris974 deleted the drop-moment-timezone branch August 25, 2026 18:13
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.

2 participants