Every calendar app, every scheduler, and every AI agent that promises to "add this to my calendar" eventually meets the same wall. A string arrives. It looks like a date. Something has to decide what it means.
JavaScript's answer to that question has been the same for thirty years: hand it to Date.parse and hope. The specification says out loud that hoping is the right word. ECMA-262 clause 21.4.3.2 licenses every engine to fall back to "any implementation-specific heuristics or implementation-specific date formats" the moment your string leaves the narrow format it knows. Mozilla's own documentation says the unreliability of Date.parse is one of the reasons the Temporal API exists at all.
So the obvious move is to wait for Temporal. Except Temporal's own design documents decline the job. The TC39 parse draft opens by saying a Temporal.parse API "is not currently planned to be implemented." Temporal is a specification for date and time values. The string layer underneath it is nobody's.
That is the gap. We fell into it while building scheduling and calendar features, and we climbed out by treating date and time expressions as what they actually are — a small formal language with a grammar. Today that work is open source.
TL;DR:
@taskade/temporal-parseris an MIT-licensed TypeScript lexer and parser for ISO 8601, RFC 3339, and RFC 9557 (IXDTF) temporal strings. It returns a typed AST instead of aDate, exposes its token stream so you can write your own grammar, has zero runtime dependencies, ships ESM and CommonJS with full types, and passes 461 tests. Read the source on GitHub →

Photo: Wikimedia Commons / LoKiLeCh / CC BY-SA 3.0. A clock that also shows a date is the everyday version of the problem this library solves — one artifact, several temporal facts, each with its own rules.
🗺️ Temporal Parser at a Glance
@taskade/temporal-parser is an open-source TypeScript library that lexes and parses ISO 8601, RFC 3339, and IXDTF temporal strings into a typed abstract syntax tree, and stringifies that tree back. It does no date arithmetic and no calendar validation. Those jobs belong to TC39 Temporal, luxon, or date-fns, after you already hold structured fields.
Three layers. The JavaScript ecosystem has excellent tooling for the second and third. The first has been served by regular expressions and Date.parse for three decades.
Temporal Parser at a Glance
| Fact | Detail |
|---|---|
| Package | @taskade/temporal-parser on npm |
| Latest version | 1.2.2, published 2026-09-11 |
| First published | 2026-01-12 |
| License | MIT, copyright Taskade |
| Author | Stan Chang (@lxcid), while building scheduling and calendar features |
| Runtime dependencies | Zero. No dependencies key, no peerDependencies |
| Module formats | Dual ESM + CommonJS through a conditional exports map, types first |
| Engines floor | Node 20 or later. CI runs Node 20, 22, and 24 |
| Size | ~12.3 KB minified, ~4.2 KB minified and gzipped, ~26 KB as shipped (the published dist is not minified) |
| Tests | 461, all passing, across 12 test files |
| Coverage | 94.19% statements, 95.13% branch, 100% functions |
| Public API | 18 runtime exports + 13 type-only exports |
| Standards | ISO 8601 (subset), RFC 3339, RFC 9557 / IXDTF |
| What it does not do | Date arithmetic · calendar validation · time-zone rule resolution · locale formatting |
Last updated: September 2026 — every figure in this table was re-derived against the published
1.2.2tarball and the repository atmain, not copied from the README. Two README lines are stale and we say so below rather than repeat them: the size is 12.3 KB minified, not 18 KB, and coverage is 94.19%, not 91%.
🧩 What a Temporal Parser Is — and Three Things It Is Not
A temporal parser reads a machine-readable date and time string and returns its structure. @taskade/temporal-parser accepts ISO 8601, RFC 3339, and RFC 9557 input and returns a typed AST with separate nodes for the date, the time, the offset, the IANA zone, and any calendar annotation. The word "temporal" is overloaded in 2026, and two of the three other things it names will send you somewhere unhelpful.
| Not this | What it actually is | How to tell |
|---|---|---|
| Temporal.io | A durable workflow-orchestration platform. Workers, activities, retries, event histories. | It runs code over time. This library never executes anything. |
| The TC39 Temporal API | The ECMAScript standard for date and time values — Temporal.PlainDate, Temporal.ZonedDateTime, arithmetic, calendars. Reached Stage 4 in March 2026. |
It holds instants. This library holds syntax, and hands the fields to Temporal. |
| NLP temporal expression parsers | Academic information-extraction systems that find "next Tuesday" or "three weeks ago" in prose. chrono-node is the popular JavaScript member of this family, and it is what most AI agents reach for when a human is doing the typing. |
They read human language. This library reads machine formats. |
@taskade/temporal-parser is the fourth thing: a compiler front-end for machine-readable date and time strings. Input is text. Output is a typed tree. Nothing in between guesses.
The distinction matters more than it sounds, because the failure modes are opposite. A natural-language parser is supposed to guess — that is the product. A format parser that guesses is a defect, and Date.parse guessing is exactly the defect this whole article is about.
🕳️ The Layer Nobody Owns
JavaScript has no owner for the date-string parsing layer, and the chain of authority proves it by closing on itself. Mozilla documents Date.parse as implementation-defined and points readers at Temporal. The TC39 Temporal proposal's own parse draft then states that a general Temporal.parse is not planned. Nothing between the raw string and a validated value is anybody's responsibility.
Mozilla's reference for Date.parse() warns that formats outside the specified one "are implementation-defined and may not work across all browsers," then adds the sentence that sends you onward: "the unreliability of Date.parse() is one of the motivations for the Temporal API to be introduced."
Follow that pointer into the Temporal proposal's own documentation and you land on the parse draft, which opens:
"This is a draft design document for a
Temporal.parseAPI, which is not currently planned to be implemented for several reasons."
The reasons are good ones. A strongly typed API sits badly with a function that parses anything. The overflow option already covers partial strings. The offset option already covers a disagreement between a numeric offset and a named zone. Temporal is right to refuse. But the refusal leaves the string layer unclaimed.
Every team that has shipped a scheduler has walked this loop and ended at the regular expressions. We did too. This library is what we built after the pile stopped being maintainable.
💥 Why Date.parse Cannot Be Trusted
Date.parse is unreliable because ECMA-262 clause 21.4.3.2 says it may be: any string outside the one specified format lets an engine "fall back to any implementation-specific heuristics," and the result is explicitly implementation-defined. Measured on Node 22 across twenty inputs, seven return NaN, eight silently resolve against local time, and three produce a confident wrong answer. This claim gets asserted often and proved rarely, so here is the proof.
The specification says so, out loud
ECMA-262 clause 21.4.3.2, verbatim:
"The function first attempts to parse the String according to the format described in Date Time String Format (21.4.1.32), including expanded years. If the String does not conform to that format the function may fall back to any implementation-specific heuristics or implementation-specific date formats."
And the closing sentence of the same clause:
"…in general, the value produced by this function is implementation-defined when given any String value that does not conform to the Date Time String Format (21.4.1.32)…"
There is exactly one place where the standard forbids the fallback. Clause 21.4.1.32.1 says that expanded years outside the representable range are "treated as unrecognizable by Date.parse and cause that function to return NaN without falling back to implementation-specific behaviour or heuristics." The fact that the specification has to say so there tells you the fallback is permitted everywhere else.
The trap almost nobody knows is also specified
Same clause 21.4.3.2:
"When the UTC offset representation is absent, date-only forms are interpreted as a UTC time and date-time forms are interpreted as a local time."
Read that twice. Two spellings of the same calendar moment resolve to different instants, by design.
| Input | Milliseconds | As UTC | As local, in America/Los_Angeles |
|---|---|---|---|
'2025-01-12' |
1736640000000 | 2025-01-12T00:00:00.000Z |
Sat Jan 11 2025 16:00:00 GMT−0800 |
'2025-01-12T00:00:00' |
1736668800000 | 2025-01-12T08:00:00.000Z |
Sun Jan 12 2025 00:00:00 GMT−0800 |
Eight hours apart, and the date-only form prints as the previous day. That is the off-by-one-day bug that has shipped in approximately every date picker ever written. The sign flips in the other hemisphere: run the same pair with TZ=Asia/Singapore and the date-only form lands eight hours later in local terms, because its absolute instant never moved — only its neighbour's did.
The zone-dependence compounds. One unchanged string, three deploy regions:
| Host time zone | Date.parse('2025-01-12T10:00:00') |
Resolved UTC |
|---|---|---|
America/Los_Angeles |
1736704800000 | 2025-01-12T18:00:00Z |
America/New_York |
1736694000000 | 2025-01-12T15:00:00Z |
Asia/Singapore |
1736647200000 | 2025-01-12T02:00:00Z |
A sixteen-hour spread across three regions, from one input, with no error and no warning.
And the sharpest version of the trap is a single missing zero:
| Input | Milliseconds | Interpretation |
|---|---|---|
'2025-01-02' |
1735776000000 | Conforming → UTC |
'2025-1-2' |
1735804800000 | Non-conforming → heuristic → local |
Eight hours apart. Zero-padding is not cosmetic. It silently selects the anchor time zone.
The empirical table
Twenty strings, measured on node v22.22.1 (V8 12.4.254.21, ICU 78.2, tzdata 2025c) with the host zone at America/Los_Angeles. new Date(s).getTime() matched Date.parse(s) on all twenty, so the trap is in the parser, not the constructor.
| Input | Date.parse result |
Class |
|---|---|---|
2025-01-12 |
2025-01-12T00:00:00.000Z |
Conforming, date-only → UTC |
2025-01-12T10:00:00 |
2025-01-12T18:00:00.000Z |
Conforming, no offset → local |
2025-01-12T10:00:00Z |
2025-01-12T10:00:00.000Z |
Conforming, explicit UTC |
2025-01-12 10:00:00 |
2025-01-12T18:00:00.000Z |
Non-conforming → heuristic, local |
2025/01/12 |
2025-01-12T08:00:00.000Z |
Heuristic, local |
01/12/2025 |
2025-01-12T08:00:00.000Z |
Heuristic, US month-first, local |
2025-1-2 |
2025-01-02T08:00:00.000Z |
Heuristic, local |
2025-01-12T10:00:00+0800 |
2025-01-12T02:00:00.000Z |
Colon-less offset → heuristic, offset honored |
2025-01-12T10:00:00+09 |
NaN | Hour-only offset rejected |
2025-01-12T10:00:00,123Z |
NaN | Comma fraction — legal ISO 8601 — rejected |
2025-02-31 |
2025-03-03T00:00:00.000Z |
Rolls over. No error |
2025-01-12T24:00:00Z |
2025-01-13T00:00:00.000Z |
Hour 24 legal, normalizes forward |
2025-W03 |
NaN | ISO week date unsupported |
2025-012 |
2025-12-01T08:00:00.000Z |
Ordinal date misread as December |
20250112 |
NaN | ISO basic format unsupported |
2025-01-12T10:00:00+08:00[Asia/Singapore] |
NaN | RFC 9557 rejected outright |
P1Y2M3D |
NaN | Duration unsupported |
2025-01-01/2025-12-31 |
NaN | Interval unsupported |
Sun Jan 12 2025 |
2025-01-12T08:00:00.000Z |
Heuristic, local |
January 12, 2025 |
2025-01-12T08:00:00.000Z |
Heuristic, local |
Seven of twenty return NaN. Thirteen parse. Of those thirteen, eight resolve against local time and move with the host machine.
Two rows deserve their own paragraph.
2025-02-31 becomes 3 March. No throw, no NaN. February 2025 has 28 days, the overflow is applied arithmetically, and you get a date three days past the end of the month. The family generalizes: 2025-02-30 lands on 2 March, 2025-04-31 on 1 May. But 2025-13-01 returns Invalid Date. So a form that validates by asking "did Date.parse return NaN?" catches a bad month and waves through a bad day.
2025-012 becomes 1 December. That string is a valid ISO 8601 ordinal date meaning 12 January 2025. V8 returns December. An eleven-month error, silently, with a plausible-looking Date object as the result.
Engines really do disagree
We measured V8 only, so we cite rather than claim for the others. Mozilla's Date.parse() reference publishes its own cross-engine table, and the first row is the same defect class we measured:
| String | Chrome (V8) | Firefox (SpiderMonkey) | Safari (JavaScriptCore) |
|---|---|---|---|
2014-02-30 |
1393718400000 → 2 Mar 2014 |
NaN |
— |
01-02-03 |
2 Jan 2003 | 3 Feb 0001 | assumes YY-MM-DD |
04 DecFoo 1995 |
818031600000 |
NaN (≤121 stops at the invalid letter) |
reads only the first three characters |
One engine invents a date. Another refuses. A third reads three letters of a word and moves on. That is the whole argument in one table.
The Mozilla meta-bug tracking this — [meta] Date.parse, amirite — has been open for a decade with dozens of dependent bugs across four engines. Its reporter opened with "Date.parse has no standard, and our implementation is a dumpster fire," and the reason nobody simply fixes it is stated just as plainly in the thread: "If Date.parse were made very strict, many web sites would break."
There is even a defensible reading in which V8 is correct. Clause 21.4.1.32 defines DD as "two decimal digits from 01 to 31", so 31 is in bounds as a field, and February's invalidity is calendrical rather than field-level. SpiderMonkey reads it the other way. Both readings survive the text. That ambiguity is not a bug in an engine — it is a hole in the contract, and no amount of careful Date.parse usage patches it.
🧪 Why a Regular Expression Cannot Replace It
The standard reaction to the table above is to write a regular expression. That reaction has its own canonical rebuttal: Ben Wilber's 2021 post "Don't parse ISO-8601 datetime strings with a regex", written after patching the same class of bug in ExoPlayer and Django. The argument is correct and the article is Python. This library is the JavaScript answer to it.
The problem is not that a regex is inelegant. The problem is what a regex returns.
| Approach | What you get back | What you cannot do next |
|---|---|---|
| One regex per format | A boolean, plus capture groups you must re-interpret | Report which part failed · round-trip · handle nesting |
Date.parse |
A millisecond instant, or NaN, or a guess |
Recover the offset, the named zone, the calendar, a duration, an interval |
| Lexer + parser | A token stream and a typed tree | Nothing a later stage still needs |
And the surface a regex has to cover is larger than it looks. These are all legal, and each one has broken a hand-rolled pattern in production somewhere:
| Edge case | Example | What usually goes wrong |
|---|---|---|
| Comma fractional separator | 2025-01-12T10:00:00,123 |
Pattern only allows .; the string is rejected or truncated |
| Space before the offset | 2012-04-23T10:20:30.400 -0200 |
Anchored pattern fails; offset silently dropped |
| Leap second | 2016-12-31T23:59:60Z |
[0-5][0-9] rejects 60, which RFC 3339 explicitly permits |
| Unknown-offset marker | 2025-01-12T10:00:00-00:00 |
Normalized to +00:00, destroying the "offset unknown" signal RFC 3339 defines |
| Negative / BC year | -0044-03-15 |
Leading - read as a separator; the Ides of March become a parse error |
| Expanded year | +002025-01-12 |
The + is unexpected |
| Critical annotation | 2025-01-12T10:00:00Z[!u-ca=gregory] |
Brackets stripped; the ! that means "reject if you do not understand me" is lost |
| Open-ended interval | 2025-01-01/ |
Treated as malformed rather than as an unbounded range |
| Lowercase designators | 2025-01-12t10:00:00z |
RFC 3339 permits lowercase; strict patterns reject it |
Nine rows, each of which adds a branch. Every branch you add widens the surface for the next false positive. That is not a maintenance problem you can out-discipline — it is what happens when you use a pattern matcher on a grammar.
📜 A Short History of the Strings We Parse
Four standards govern machine-readable timestamps, and each exists because the previous one left a complaint open: ISO 8601 in 1988 defined the language, RFC 3339 in July 2002 narrowed it into an unambiguous Internet profile, ISO 8601 split into parts 1 and 2 in 2019, and RFC 9557 in April 2024 finally added named time zones. None is a complete programming model of time, and none claims to be.
| Year | What landed | What it added | What it still could not do |
|---|---|---|---|
| 1988 | ISO 8601, first edition (June) | A civil date and time interchange language: calendar dates, week dates, ordinal dates, durations, intervals | Name a time zone; pin a calendar system |
| 1991 | ISO 8601:1988/Cor 1 | Corrections, withdrawn in 2000 | — |
| 2000 | ISO 8601, second edition | Permitted two-digit truncated years | Two-digit years were a mistake; removed in 2004 |
| July 2002 | RFC 3339 — "Date and Time on the Internet: Timestamps" | A narrow, unambiguous profile for Internet protocols. Mandatory full date, T separator, and an offset of Z or ±HH:MM. Leap second :60 explicitly legal. -00:00 means "UTC known, local offset unknown" |
Still no named zone, and it drops week dates, ordinal dates, durations, and intervals |
| 2004 | ISO 8601, third edition (1 December) | Consolidation; the version most tooling still targets | Still no named zone |
| 2019 | ISO 8601-1 and ISO 8601-2 (February–March) | The standard splits: -1 basic rules, -2 extensions |
Still no named zone |
| March 2021 | TC39 Temporal reaches Stage 3 | A real JavaScript API for date and time values, conditional on IETF standardizing the serialization format — which became RFC 9557 | Declines to ship a general string parser |
| April 2024 | RFC 9557 — IXDTF, Standards Track, updates RFC 3339 | Optional bracketed suffixes on an RFC 3339 timestamp: one IANA time-zone name plus any number of [key=value] annotation tags, each with an optional ! critical flag |
Nothing about arithmetic or validation — it is a serialization format |
| March 2026 | TC39 Temporal reaches Stage 4 | Merged toward ECMA-262 and ECMA-402 | Still declines to ship a general string parser |
| 2026 | @taskade/temporal-parser |
A lexer and typed AST for the forms above, with the token stream exposed | Date arithmetic, calendar validation, zone-rule resolution — deliberately |
A note on sourcing: RFC 3339 and RFC 9557 are free to read and every claim about them above was taken from the RFC text. The ISO standards are paywalled, so the ISO edition dates come from secondary sources and the fourth-edition publication date is given to the month rather than the day, because the two best secondary sources disagree by four weeks. We would rather be vague than precisely wrong.
🆚 ISO 8601 vs RFC 3339 vs RFC 9557
ISO 8601 is the broad interchange language, RFC 3339 is a narrow Internet profile of it, and RFC 9557 extends RFC 3339 with bracketed suffixes for a named time zone and a calendar. The three overlap without nesting cleanly: RFC 3339 forbids forms ISO 8601 allows, and RFC 9557 adds forms neither of the others can express. That is why a single regular expression can never be right.
| Capability | ISO 8601 | RFC 3339 (§5.6) | RFC 9557 / IXDTF |
|---|---|---|---|
Calendar date 2025-01-12 |
✅ | ✅ | ✅ |
Reduced precision 2025, 2025-01 |
✅ | ❌ full date required | ❌ |
Week date 2025-W03-1 |
✅ | ❌ | ❌ |
Ordinal date 2025-012 |
✅ | ❌ | ❌ |
Basic format 20250112 |
✅ | ❌ extended only | ❌ |
T separator |
Optional | Required (lowercase permitted) | Required |
Offset Z |
✅ | ✅ | ✅ |
Offset +08:00 |
✅ | ✅ | ✅ |
Offset +0800 / +09 |
✅ | ❌ — ±HH:MM only in §5.6 |
❌ |
-00:00 = offset unknown |
Not defined | ✅ defined meaning | ✅ |
Leap second :60 |
✅ | ✅ explicitly legal | ✅ |
Comma fraction 10:00:00,5 |
✅ | ❌ dot only | ❌ |
Duration P1Y2M3DT4H5M6S |
✅ | ❌ | ❌ |
Interval 2025-01-01/2025-12-31 |
✅ | ❌ | ❌ |
Negative / BC year -0044-03-15 |
✅ | ❌ | ❌ |
Named zone [Asia/Singapore] |
❌ | ❌ | ✅ this is the point |
Calendar tag [u-ca=hebrew] |
❌ | ❌ | ✅ |
Critical flag [!u-ca=iso8601] |
❌ | ❌ | ✅ |
One nuance worth stating precisely, because a careless version of it is wrong. The compact offsets +0530 and +09 are illegal under RFC 3339 §5.6, whose grammar is time-numoffset = ("+" / "-") time-hour ":" time-minute. But Appendix A of that same RFC reproduces the ISO 8601 ABNF with both the colon and the minutes optional, so the compact forms are legal ISO 8601. Saying flatly "+0530 is not RFC 3339" is right about the normative profile and wrong about the appendix of the document you are citing. Our parser accepts them and normalizes them to ±HH:MM on the way out.
Offset, zone, and calendar are three different facts
The offset records where the clock stood at that instant. The zone records which rules govern the clock going forward. They can disagree the moment a government changes daylight-saving policy — and governments do, with a few weeks' notice. If you store only the offset, a recurring 10:00 meeting in Singapore drifts the next time the rules move. If you store only the zone, you cannot reconstruct what the original sender's clock said. You need both, and RFC 9557 is the only one of the three standards that can write both down.

Photo: Wikimedia Commons / Interfase / CC BY-SA 4.0. One machine, several temporal languages running at once — clock time, calendar date, and an astronomical reckoning that answers to different rules. ISO 8601, RFC 3339, and RFC 9557 are the same layering, written as text.
🚫 Why TC39 Temporal Will Not Ship Temporal.parse()
TC39 Temporal will not ship a general Temporal.parse() because its own parse draft says the API "is not currently planned to be implemented." A strongly typed API conflicts with a function that parses anything, and the existing overflow and offset options already resolve the two ambiguities such a function would expose. Temporal reached Stage 4 in March 2026 without one, and that is a deliberate choice rather than an omission.
The three reasons given in the parse draft are worth reading in full if you work on this problem, but they compress to this: Temporal's whole value is that a PlainDate is known to be a plain date. A parse() that returns "whatever this string turned out to be" pushes a discriminated union back into your code and undoes the type safety that made the API worth having. Temporal's existing overflow and offset options already resolve the two ambiguities a general parser would have to expose.
The practical consequence is a list of strings Temporal will not take:
| Form | Example | Temporal |
|---|---|---|
| Week date | 2026-W34-4 |
❌ rejected |
| Ordinal date | 2026-232 |
❌ rejected |
| Interval | 2025-01-01/2025-12-31 |
❌ rejected |
| Two-digit year | 25-01-12 |
❌ rejected |
| Six-digit expanded year | +002025-01-12 |
✅ accepted (an ISO 8601 extension RFC 9557 lacks) |
| IXDTF with named zone | 2025-01-12T10:00:00+08:00[Asia/Singapore] |
✅ accepted |
So even in a world where Temporal is everywhere, a string arriving from a calendar export, a partner API, or a user's clipboard still has to be understood before Temporal will look at it.
And Temporal is not yet everywhere. Read 2026-09-12:
| Runtime | Ships Temporal by default | Since |
|---|---|---|
| Firefox | ✅ 139 | 2025-05-27, first to ship |
| Chrome | ✅ 144 | 2026-01-13 |
| Edge | ✅ 144 | 2026-01-21 |
| Node.js | ✅ 26 | 2026-05-05 |
| Safari | ❌ no release | Technology Preview only |
| Global support | ~69% | Not yet Baseline — Safari is the blocker |
There is one more asymmetry inside Temporal itself that catches people, and it is the exact thing a code reviewer flagged on the first draft of this article. Temporal.PlainDate.from behaves differently depending on whether you hand it a string or a property bag:
Typescript
// Property bag: the default is overflow: 'constrain'. It does NOT throw.
Temporal.PlainDate.from({ year: 2025, month: 2, day: 31 });
// → 2025-02-28 (silently clamped)Temporal.PlainDate.from({ year: 2025, month: 2, day: 31 }, { overflow: 'reject' });
// → RangeError: Invalid day: 31; must be between 1-28
// String: rejected unconditionally. overflow is inert here.
Temporal.PlainDate.from('2025-02-31');
// → RangeError: Invalid day: 31; must be between 1-28
The Temporal specification explains the split. On the property-bag path, ToTemporalDate reads the overflow option and passes it to CalendarDateFromFields. On the string path it reads the option — so an invalid value still throws — and then never binds the result, because the grammar already rejected the date: "It is a Syntax Error if IsValidDate of DateSpec is false."
This matters directly for anyone feeding our AST into Temporal. The AST hands you a property bag. parseTemporal('2025-02-31') returns a well-formed node with day: 31, and passing those fields into PlainDate.from without options gets you 28 February and no complaint. Pass { overflow: 'reject' }. That one option is the seam between "structurally valid" and "a day that exists."
⚠️ The same asymmetry applies to time.
Temporal.PlainTime.from({ hour: 25 })returns23:00:00.Temporal.PlainTime.from('25:00')throws.
🔤 Parse Time Like a Language
Everything above is the case for the problem. Here is the shape of the answer, which is not novel at all — it is how compilers have handled input since the 1970s, applied to a domain that somehow never got it.
Two stages. The lexer turns characters into tokens. The parser turns tokens into a tree. Meaning is decided later, by somebody else.
The split is the entire design, and the reason it is worth the extra stage is the branch on the left: the token stream is public. A date library hides the string. A compiler front-end lets you see it. If you have ever tried to underline the invalid part of an offset in a form field, or preserve a comma fractional second while still computing with the value, you have already wanted that branch.
This is the same move Mermaid made for diagrams, Markdown made for prose, and FFmpeg made for multimedia containers: define a grammar, lex it, parse it, and refuse to let stage one guess at stage three's job. It is also, at a much larger scale, what compilers have done to source code since the 1970s. Nothing here is clever. It is only unusual in this particular domain.
🧬 Inside the Lexer
lexTemporal does not return a Date. It returns a token stream, and that stream is a public export rather than an internal detail. Lexing 2025-01-12T10:00:00+08:00 yields 16 tokens; the optional combineTimezoneOffsets pass folds the four offset tokens into one and leaves 13. Every token carries start and end character positions, which is what makes a precise error underline possible.
Typescript
import { lexTemporal, combineTimezoneOffsets } from '@taskade/temporal-parser';const raw = lexTemporal('2025-01-12T10:00:00+08:00');
// 16 tokens: Number, Dash, Number, Dash, Number, T, Number, Colon, …, Plus, Number, Colon, Number, EOF
const combined = combineTimezoneOffsets(raw);
// 13 tokens — the four offset tokens fold into one:
// { type: 'TZOffset', value: '+08:00', tokens: [ …the 4 originals… ], start: 19, end: 25 }
combineTimezoneOffsets is an optional post-pass, and it is context-sensitive rather than a blind merge. It refuses to fold after a Z token, because Z and a numeric offset are mutually exclusive, and it refuses to fold after a closing bracket, because RFC 9557 puts the offset before the annotations. A third rule is why a bare 2025-01 never gets mangled into something with an offset: a sign only folds when the number in front of it is itself preceded by a colon or a dot — which is to say, only after a real time component.
Note that the combined token keeps its originals in a tokens array, and every token carries start and end character positions. That is not decoration. It is what lets you point at column 19 of the user's input and say "this offset is the problem" instead of showing them Invalid Date.
2025-01-12T10:00:00+08:00
│ │ │ ││ │ │ └────── offset (token indices 11–14, zero-based → 1)
│ │ │ ││ │ └───────── second
│ │ │ ││ └──────────── minute
│ │ │ │└─────────────── hour
│ │ │ └──────────────── T designator
│ │ └─────────────────── day
│ └────────────────────── month
└─────────────────────────── year
The state machine underneath is small, which is the point — a grammar you can hold in your head is a grammar you can extend without fear:
A second parser on the same tokens is a first-class use case, not a hack. The shipped parseTemporal implements one grammar — timestamps, durations, and ranges. Your product may want a narrower one: timestamps only, or durations that reject weeks because your billing system has no concept of a week. Keep the lexer, write the smaller parser, and you inherit every character-position and offset-folding fix we make.
🌳 Inside the AST
The AST is a discriminated union two levels deep, with three concrete top-level nodes — RangeAst, DateTimeAst, and DurationAst — and nine kind string literals across the whole tree. Every node is narrowable by kind, and the optional fields are genuinely optional, so a partial date such as 2025-01 arrives with day absent rather than invented. The shape matters if you are writing an exhaustive switch:
Typescript
type TemporalAst = RangeAst | ValueAst;
type ValueAst = DateTimeAst | DurationAst;
So there are three concrete top-level nodes, but a RangeAst holds ValueAst children rather than arbitrary TemporalAst children — ranges do not nest inside ranges.
Every node is discriminated by a kind string literal, and there are nine of them across the whole tree:
| Node type | kind literal |
Fields |
|---|---|---|
RangeAst |
'Range' |
start, end — either may be null for an open-ended range |
DateTimeAst |
'DateTime' |
date, optional time, optional offset, optional timeZone, annotations |
DurationAst |
'Duration' |
years, months, weeks, days, hours, minutes, seconds, secondsFraction, raw |
DateAst |
'Date' |
year required; month and day optional — that is how 2025-01 survives |
TimeAst |
'Time' |
hour, minute, optional second, optional fraction |
OffsetAst |
'UtcOffset' or 'NumericOffset' |
UtcOffset carries nothing else; NumericOffset carries sign, hours, minutes, raw |
TimeZoneAst |
'IanaTimeZone' |
id, critical |
AnnotationAst |
'Annotation' |
raw, critical, pairs |
Three details in that table repay a second look.
month and day are optional on DateAst. A Date object cannot represent "January 2025" — it has to pick a day. The AST simply does not have one, and your code can decide what absence means. That single property is the reason we could build a date-time input field that round-trips partial values without inventing them.
fraction and secondsFraction are strings, not numbers. PT1.5S gives you seconds: 1, secondsFraction: '5'. Keeping the digits as text preserves trailing-zero precision that a float would erase, and 10:00:00.100 is not the same string as 10:00:00.1 even though it is the same instant.
critical is carried on both time zones and annotations. That is the RFC 9557 ! flag, and it is the field a regex-and-strip approach destroys.
Here is a complete timestamp with everything on it:
Typescript
import { parseTemporal } from '@taskade/temporal-parser';const ast = parseTemporal('2025-01-12T10:00:00+08:00[Asia/Singapore]');
// {
// kind: 'DateTime',
// date: { kind: 'Date', year: 2025, month: 1, day: 12 },
// time: { kind: 'Time', hour: 10, minute: 0, second: 0 },
// offset: { kind: 'NumericOffset', sign: '+', hours: 8, minutes: 0, raw: '+08:00' },
// timeZone: { kind: 'IanaTimeZone', id: 'Asia/Singapore', critical: false },
// annotations: []
// }
That is the same string Date.parse returns NaN for. ECMA-262 clause 21.4.1.32 Note 2 says so deliberately: "This format does not support annotations with a time zone name as defined in RFC 9557, only a numeric representation of the time zone offset." The omission is not an oversight in the engines. It is the specified scope, and the scope has a hole in it.
✅ What Parses, What Fails, and What Lies
Temporal Parser handles calendar dates, partial dates, times, offsets, IXDTF annotations, durations, and ranges — and it rejects week dates, ordinal dates, expanded years, negative durations, and standalone ISO times. Three inputs are worse than a rejection: 2025-012, 20250112, and 2025-02-31 each return a confident, wrong AST with no error at all. Every row below was executed against the published 1.2.2 ESM build.
| Input | Parses? | kind |
Note |
|---|---|---|---|
2025 |
✅ | DateTime |
{ year: 2025 } — no month, no day |
2025-01 |
✅ | DateTime |
{ year: 2025, month: 1 } |
2025-01-12 |
✅ | DateTime |
full date, no time key |
-0044-03-15 |
✅ | DateTime |
year: -44 — the Ides of March, astronomical numbering |
0000-01-01 |
✅ | DateTime |
year: 0, which is 1 BC |
2025-01-12T10:00:00Z |
✅ | DateTime |
offset: { kind: 'UtcOffset' } |
2025-01-12T10:00:00+08:00[Asia/Singapore] |
✅ | DateTime |
offset and named zone, both kept |
2025-01-12T10:00:00+08:00[!u-ca=iso8601] |
✅ | DateTime |
annotations[0].critical === true |
2025-01-12T10:00:00,123 |
✅ | DateTime |
fraction: '123' from a comma |
2025-01-12T24:00:00 |
✅ | DateTime |
hour: 24 stored verbatim — no range check |
P1Y2M3DT4H5M6S |
✅ | Duration |
six components — weeks stays absent |
PT1.5S |
✅ | Duration |
seconds: 1, secondsFraction: '5' |
P1W |
✅ | Duration |
weeks: 1 |
2025-01-01/2025-12-31 |
✅ | Range |
both sides DateTime |
/2025-12-31 |
✅ | Range |
start: null |
2025-01-01/ |
✅ | Range |
end: null |
2025-01-01/P1Y |
✅ | Range |
end is a Duration |
P1Y/2025-12-31 |
✅ | Range |
start is a Duration |
+002025-01-12 |
❌ | — | ParseError — expanded years unsupported |
2025-W03 / 2025-W03-1 |
❌ | — | ParseError — week dates unsupported |
-P1D |
❌ | — | ParseError — negative durations unsupported |
T10:30 |
❌ | — | ParseError at token 0 — see issue #13 |
10:30:00 |
❌ | — | ParseError — 10 is read as a year. Use parseTimeString |
2025-01-12T10:00:00,123+08:00 |
❌ | — | A real bug. Comma fraction plus offset fails, though each works alone |
2025-02-31 |
⚠️ yes | DateTime |
Returns day: 31. No calendar validation, by design |
2025-012 |
⚠️ yes | DateTime |
Returns { year: 2025, month: 12 }. Ordinal date silently misread |
20250112 |
⚠️ yes | DateTime |
Returns year: 20250112. Basic format swallowed as one year |
The three rows that matter most are the ones that do not throw
┌──────────────────────────────────────────────────────────┐
│ FAILS LOUDLY — safe. You will find these in testing. │
│ │
│ ✗ 2025-W03 ParseError week date │
│ ✗ +002025-01-12 ParseError expanded year │
│ ✗ -P1D ParseError negative duration │
│ ✗ T10:30 ParseError standalone time, issue 13 │
│ │
│ FAILS QUIETLY — dangerous. Only a review finds these. │
│ │
│ ⚠ 2025-02-31 → day 31 no calendar validation │
│ ⚠ 2025-012 → month 12 ordinal misread │
│ ⚠ 20250112 → year 20250112 basic format swallowed │
└──────────────────────────────────────────────────────────┘
2025-02-31 is deliberate and documented — structure is not existence, and the README says so. The other two are honest gaps, and we would rather you learn them here than from a production incident.
The operating rule is the same either way: check the AST, not merely the absence of an exception. A parser that returns a typed tree makes that check possible; Date.parse returning a plausible Date for 2025-012 does not.
🔁 Round-Trip: Stringify and Back
The package ships nine stringifiers, one per node type, so you can re-emit a whole tree or a single field. Stringify normalizes toward the RFC 3339 profile: a compact +0530 offset becomes +05:30, a short +09 becomes +09:00, and a European comma fraction becomes a dot. That makes a parse-then-stringify pass a legal way to convert ISO 8601 input into RFC 3339 output at an API boundary.
| Export | Takes | Emits |
|---|---|---|
stringifyTemporal |
any TemporalAst |
the canonical full string |
stringifyDateTime |
DateTimeAst |
2025-01-12T10:00:00+08:00[Asia/Singapore] |
stringifyDate |
DateAst |
2025-01-12 |
stringifyTime |
TimeAst |
10:00:00 |
stringifyOffset |
OffsetAst |
+08:00 or Z |
stringifyTimeZone |
TimeZoneAst |
[Asia/Singapore] |
stringifyAnnotation |
AnnotationAst |
[u-ca=gregory] or [!u-ca=iso8601] |
stringifyDuration |
DurationAst |
P1Y2M3DT4H5M6S |
stringifyRange |
RangeAst |
2025-01-01/2025-12-31 |
Stringify is also where normalization happens, and it normalizes toward the RFC 3339 profile:
Typescript
import { parseTemporal, stringifyTemporal } from '@taskade/temporal-parser';stringifyTemporal(parseTemporal('2025-01-12T10:00:00+0530'));
// → '2025-01-12T10:00:00+05:30' compact offset → colon form
stringifyTemporal(parseTemporal('2025-01-12T10:00:00+09'));
// → '2025-01-12T10:00:00+09:00' short offset → colon form
stringifyTemporal(parseTemporal('2025-01-12T10:00:00,123'));
// → '2025-01-12T10:00:00.123' comma fraction → dot
So a parse-then-stringify pass is a legal way to turn ISO 8601 input into RFC 3339 output, which is a common need at an API boundary and is otherwise annoying to do correctly.
That round-trip is the review standard for the project. If a new production string parses, stringifies, and parses again without losing the zone, the offset, or a critical annotation, it belongs in the suite. If stringify invents a field the input never carried, it does not. The 461 tests exist because those two sentences are easy to say and easy to get wrong.
One honest caveat, which we found while fact-checking this article rather than in the test suite: 2025-01-12T10:00:00,123+08:00 — a comma fraction followed by an offset — throws, even though 2025-01-12T10:00:00,123 parses and 2025-01-12T10:00:00.123+08:00 parses. That combination is a defect, not a design decision, and it is in the open queue.
⚠️ Failure Modes Worth Knowing
Eleven behaviors surprise people, and most of them follow from one design decision: Temporal Parser reports structure and refuses to infer meaning. It will hand you February 31 without complaint, it will not accept a bare Z in parseOffset, and it needs { overflow: 'reject' } when you pass its fields to Temporal. Read these before you ship it.
- Structure is not existence.
2025-02-31is a well-formed string and not a civil day. Leap days, month lengths, and daylight-saving gaps are decided after the AST. - Do not validate against
Date.parse. The engines disagree with one another, none of them implements RFC 9557, and the specification licenses the disagreement. It is not a reference implementation. - Watch the three silent misparses.
2025-012,20250112, and2025-02-31return an AST. Assert on the fields you expect, not on the absence of a throw. parseTemporalhas no default export. Useimport { parseTemporal } from '@taskade/temporal-parser'. A default import resolves toundefinedunder ESM and breaks outright underverbatimModuleSyntax.- Standalone ISO times are not supported yet. A bare
T10:30or10:30:00throws inparseTemporal. UseparseTimeStringfor human forms, and track issue #13. parseOffsetdoes not acceptZ. It throwsOffset must start with + or -. OnlyparseTemporalproduces theUtcOffsetvariant. This one surprises people who read the return type first.parseOffsetis looser than either standard. It accepts+5:30with a single-digit hour, which is illegal under RFC 3339 §5.6 and under the ISO 8601 ABNF in that RFC's Appendix A. Validate thehoursfield if you need strictness.- Offset and zone can disagree.
+08:00is where the clock was;[Asia/Singapore]is which rules apply later. A policy change separates them. Keep both, and prefer the zone for anything recurring. - A duration without a start instant is not a length of time.
P1Mis one month. One month after 31 January is a question this library will not answer and should not. - Feeding the AST into Temporal needs
{ overflow: 'reject' }. The AST is a property bag, and the property-bag path defaults toconstrain. Without that option, 31 February becomes 28 February and nothing tells you. - The hyphen is overloaded. In
-0044-03-15the leading-is a year sign. In2025-01-01/2025-12-31the/is a range separator, not division. Do not split on punctuation.
If a string fails and you believe it should not, open an issue with the exact input. Production strings are how the suite reached 461 cases, and the weird ones are worth more than the tidy ones.
🧰 Temporal Parser vs Temporal, luxon, date-fns, and chrono
Temporal Parser competes with almost nothing, because it occupies a different layer from every popular date library. date-fns, Day.js, luxon, and Moment do arithmetic and formatting on values you already hold; chrono-node reads human prose; TC39 Temporal models instants. Only this package takes a machine-format string and returns its syntax tree. It sits underneath the libraries you already use, not beside them.
| Library | Layer | Input | Output | Weekly downloads |
|---|---|---|---|---|
@taskade/temporal-parser |
Syntax | an ISO 8601 / RFC 3339 / IXDTF string | a typed AST + token stream | new |
| TC39 Temporal | Values | RFC 9557 subset, or fields | PlainDate, ZonedDateTime, Duration |
2.6M (polyfill) |
| date-fns | Arithmetic + format | a Date |
a Date or a string |
69.5M |
| Day.js | Arithmetic + format | a Date-ish |
a wrapped value | 51.8M |
| luxon | Arithmetic + format + zones | a Date or ISO string |
DateTime |
28.1M |
| Moment | Legacy, in maintenance | almost anything | a wrapped value | 25.7M |
| chrono-node | Natural language | "next Tuesday at 3pm" |
a parsed result | 1.5M |
iso-datestring-validator |
Validation only | a string | a boolean | 151K |
Download figures read from the npm registry on 2026-09-12; they drift weekly.
Seventy million weekly downloads of date-fns is not competition — it is the size of the layer sitting on top of the one nobody filled. Every one of those installs receives a Date from somewhere, and somewhere is usually Date.parse.
The other useful thing that chart shows is a hole: iso-datestring-validator at 151K weekly downloads returns a boolean. That is real demand for structural checking, served by the least informative possible return type. A tree tells you why.
🏗️ How Taskade Uses It in Production
Temporal Parser runs in production at Taskade in three places, and each one wanted a different thing from the AST: a date-time input field that fills missing components without inventing them, an automation schedule trigger that parses the time strings an AI agent writes, and a calendar picker that reduces any stored precision to a plain date. It was not built as a demo.
1. A date-time field that respects what the user did not type
A stored value might be 2025-01, or 2025-01-12, or a full timestamp with a zone. A picker needs a complete value. The naive fix is to concatenate defaults onto the string, which is how you end up with 2025-01-01T00:00:00 in a database column that was supposed to mean "January".
With an AST you fill the gaps on the tree, where absence is visible, and re-emit:
Typescript
import { parseTemporal, stringifyDateTime, type TemporalAst } from '@taskade/temporal-parser';function normalize(value: string, granularity: Granularity, userTimezone: string) {
let ast: TemporalAst;
try {
ast = parseTemporal(value);
} catch {
return null;
}
if (ast.kind !== 'DateTime') {
return null;
}
if (ast.date.month == null) ast.date.month = 1;
if (ast.date.day == null) ast.date.day = 1;
if (granularity !== 'day' && ast.time == null) {
ast.time = { kind: 'Time', hour: 0, minute: 0, second: 0 };
}
if (ast.time != null && ast.timeZone == null) {
ast.timeZone = { kind: 'IanaTimeZone', id: resolveTimezone(userTimezone) ?? 'Etc/UTC', critical: false };
}
return stringifyDateTime(ast);
}
The ast.date.month == null check is the whole point. Date cannot express that question. The kind !== 'DateTime' guard is the second half: a duration is not a bad date-time, it is a different node, and the discriminated union makes rejecting it a one-liner.
2. Parsing the timestamps an AI agent writes
This is the use case that turned an internal helper into a library worth open-sourcing.
An AI agent asked to schedule something emits a time as text. Sometimes it is 09:00:00. Sometimes it is 9:07 AM. Sometimes it is 14:30. A model is not a form validator, and Date.parse on a bare time string returns NaN or something creative. The same is true whether the string came from an agent's tool call, an automation trigger, or a prompt a user typed by hand.
Typescript
import { parseTimeString, type TimeAst } from '@taskade/temporal-parser';export function tryParseToTime(value: unknown): TryParseToTimeResult {
if (typeof value !== 'string') {
return { success: false, error: badRequest('Time value must be a string') };
}
try {
return { success: true, time: parseTimeString(value) };
} catch (error) {
return {
success: false,
error: badRequest(Invalid time format: "${value}". Expected "14:30:00" or "9:07 AM", { cause: error }),
};
}
}
parseTimeString handles 14:30:00, 14:30, 9:07 AM, 2:30PM without the space, 2:30 p.m. with the periods, 12:00 AM as hour 0, 12:00 PM as hour 12, and 10:30:45.123 with a fraction. It returns { kind: 'Time', hour: 14, minute: 30, second: 0 } — a shape a scheduler can act on, and a shape you can quote back in an error message when the model gets it wrong.
That else branch is the argument. An agent that receives "invalid format, expected this shape" can retry correctly. An agent that receives a confidently wrong Date schedules the job eleven months late and nobody finds out until the job does not run.
Timestamps are where AI reliability quietly fails. A model can reason beautifully about a deadline and still hand the runtime a string no engine agrees on. The fix is not a better prompt. It is a parser with a grammar.
3. A calendar picker that reads any stored shape
The third site is the smallest and the most representative. An automation property holds a date as a string of unknown precision; the calendar popup needs a plain YYYY-MM-DD to highlight a day:
Typescript
import { parseTemporal, stringifyDate } from '@taskade/temporal-parser';const ast = parseTemporal(value);
if (ast.kind !== 'DateTime') return null;
return stringifyDate({
kind: 'Date',
year: ast.date.year,
month: ast.date.month ?? 1,
day: ast.date.day ?? 1,
});
Parse, narrow on kind, read optional fields with explicit defaults, re-emit with the matching stringifier. Four lines, no regex, and every default is visible in the diff.
🛠️ Build Your Own Parser on the Lexer
Because lexTemporal and combineTimezoneOffsets are public, you can build a stricter grammar on the shipped token stream instead of forking the library. That is the part that justifies exposing the lexer at all. Suppose your product accepts timestamps only — no durations, no ranges — and you want a hard failure rather than a surprising Duration node reaching a scheduler downstream.
You do not need to fork the library. You need eight lines:
Typescript
import { parseTemporal, type DateTimeAst } from '@taskade/temporal-parser';export function parseTimestampOnly(input: string): DateTimeAst {
const ast = parseTemporal(input);
if (ast.kind !== 'DateTime') {
throw new TypeError(Expected a timestamp, received a ${ast.kind});
}
return ast;
}
And when you need to go below the parser — a syntax highlighter, an error underline, a linter for a config file full of cron-adjacent timestamps — you take the tokens directly:
Typescript
import { lexTemporal, combineTimezoneOffsets } from '@taskade/temporal-parser';export function highlight(input: string) {
return combineTimezoneOffsets(lexTemporal(input)).map((token) => ({
text: input.slice(token.start, token.end),
type: token.type,
start: token.start,
end: token.end,
}));
}
Every token carries its character range, so the editor can paint the offset red without re-scanning the string. That is the surface a helper returning an instant can never offer, and it is why the library is shaped this way.
🚀 Quick Start
Install @taskade/temporal-parser from npm and import parseTemporal as a named export — there is no default export. The function takes an ISO 8601, RFC 3339, or IXDTF string and returns a typed AST, or throws ParseError with a token index. The package requires Node 20 or later, ships both ESM and CommonJS, and adds no runtime dependencies.
Bash
npm install @taskade/temporal-parser
Typescript
import { parseTemporal, stringifyTemporal } from '@taskade/temporal-parser';const dt = parseTemporal('2025-01-12T10:00:00+08:00[Asia/Singapore]');
const duration = parseTemporal('P1Y2M3DT4H5M6S');
const range = parseTemporal('2025-01-01/2025-12-31');
const ides = parseTemporal('-0044-03-15'); // year: -44
stringifyTemporal(dt);
// → '2025-01-12T10:00:00+08:00[Asia/Singapore]'
Standalone helpers, for the strings that are not a full timestamp:
Typescript
import { parseOffset, parseTimeString } from '@taskade/temporal-parser';parseOffset('+08:00');
// { kind: 'NumericOffset', sign: '+', hours: 8, minutes: 0, raw: '+08:00' }
parseTimeString('2:30 PM'); // hour: 14
parseTimeString('14:30'); // hour: 14
parseTimeString('2:30PM'); // no space
parseTimeString('2:30 p.m.'); // with periods
parseTimeString('12:00 AM'); // midnight, hour: 0
parseTimeString('12:00 PM'); // noon, hour: 12
Handing the AST to Temporal — and note the option that is doing the work:
Typescript
const ast = parseTemporal('2025-02-31'); // parses: structure is validTemporal.PlainDate.from(
{ year: ast.date.year, month: ast.date.month ?? 1, day: ast.date.day ?? 1 },
{ overflow: 'reject' }, // ← without this you silently get 2025-02-28
);
// → RangeError: Invalid day: 31; must be between 1-28
The complete public surface is 18 runtime exports and 13 type-only exports:
| Group | Exports |
|---|---|
| Parsing | parseTemporal, parseTimeString, parseOffset |
| Lexing | lexTemporal, combineTimezoneOffsets |
| Stringifying | stringifyTemporal, stringifyDateTime, stringifyDate, stringifyTime, stringifyOffset, stringifyTimeZone, stringifyAnnotation, stringifyDuration, stringifyRange |
| Errors | ParseError, LexError |
| Enums | TokType, CombinedTokType |
| Types | TemporalAst, ValueAst, DateTimeAst, DurationAst, RangeAst, DateAst, TimeAst, OffsetAst, TimeZoneAst, AnnotationAst, Token, AnyToken, CombinedToken |
🎮 Try the Playground
The fastest way to understand the library is to watch a string become tokens. The interactive playground renders both outputs side by side — the color-coded lexer tokens on one side, the parsed AST on the other. Paste a timestamp, a duration, a range, or an IXDTF annotation and read what comes out.
It also states its own boundary honestly, which is worth quoting because it is the same boundary this whole article draws: "the parser does not verify that the identifier exists in IANA data, resolve its time-zone rules, or check that the offset agrees with the named zone." Structure, not semantics. Semantics belong to the next layer.
Stan Chang's technical announcement is the two-minute author version, and it names the thesis directly: "most libraries rely on heuristics or regexes that do not cover the full complexity of real-world strings."
A sibling package, @taskade/uri-parser, applies the same compiler approach to URIs (RFC 3986) with host-path and no-scheme forms. Use the platform URL constructor unless you need a lossless AST. The two are a pair, not a platform.
🔗 Open Source
Temporal Parser is MIT-licensed and developed in the open at taskade/temporal-parser, with the package published to npm under the @taskade scope and an interactive playground hosted on the author's site. Issues and pull requests are welcome, and the most valuable contribution is not a feature — it is a real production string that the parser gets wrong.
| Install | npm install @taskade/temporal-parser |
| GitHub | github.com/taskade/temporal-parser |
| npm | @taskade/temporal-parser |
| Playground | lxcid.com/tools/temporal-playground |
| Author write-up | Stan Chang's announcement |
| License | MIT |
| Sibling | @taskade/uri-parser |
Issues and pull requests are welcome, and the most useful contribution is not a feature — it is a string that failed in your production system. Paste it into the playground, screenshot the result, and open an issue. That is how the suite grew to 461 cases, and it is how the three silent misparses above got found.
🧬 Where These Strings Show Up in Taskade
A temporal parser is not a calendar. It is the front door to one, and in a workspace where AI agents write the timestamps, that door gets used constantly. Taskade passes ISO strings between projects, calendar view, Google Calendar sync, calendar feeds for Apple Calendar and Outlook, automation schedules, and agents that extract deadlines from documents — every handoff is a string somebody has to read correctly.
Dates move through projects, calendar view, and the Taskade calendar itself. Google Calendar sync, the Google Calendar integration, and calendar feeds pass ISO strings in both directions with Apple Calendar, Google Calendar, and Outlook. Automations fire on due dates and on schedules a human or a model described in words. Custom AI agents extract deadlines from documents and hand them to a runtime that has to act on them. Templates bake timestamps into sample content that must still parse a year later, and the apps in the community gallery inherit every one of those strings.
The dotted edge is the one that pays for the library. When the parser refuses, it refuses with a token index, and an agent can correct a format it can see. That loop — Memory feeds Intelligence, Intelligence triggers Execution, Execution creates Memory — only closes if every handoff between them is unambiguous. A timestamp nobody agrees on breaks the loop quietly.
Developer documentation starts at the overview and the /docs hub. The hosted MCP surface is a separate open-source line — connect a workspace to your editor — and Temporal Parser does not depend on it.
▲ ■ ● Structure before meaning. A parser that hands you a tree lets every later stage be honest about what it knows and what it is guessing. That is the same discipline that makes an agent workspace trustworthy: state you can inspect, not state you have to trust. Build your first Taskade Genesis app free →
🔮 Quo Vadis, Temporal Parsing?
The roadmap is short and public: standalone ISO 8601 times are the headline gap and the repository's only open issue, week dates and ordinal dates are the next tier, negative durations block clean round-tripping through Temporal, and one comma-fraction-plus-offset defect needs a fix. The ordinal case outranks the rest, because a silent misparse is a worse bug than a missing feature.
Standalone ISO 8601 times are the headline gap. Clause 5.4 of ISO 8601 permits hh:mm:ss, hh:mm:ss.sss, and reduced forms such as hh:mm with no date component. parseTemporal rejects all of them; parseTimeString covers the human-facing forms but is a different function with a different return type. This is tracked as issue #13, the repository's only open issue, and a draft pull request has been waiting on review since January. It should land.
Week dates and ordinal dates are the next tier. 2026-W34-4 and 2026-232 are both legal ISO 8601, both rejected today, and the ordinal case is worse than a rejection because 2025-012 currently returns the wrong month rather than an error. A silent misparse is a higher-priority bug than a missing feature.
Basic format — 20250112 — has the same shape of problem, and parseTimeString already gained basic-format support in 1.2.1, so the precedent exists on the time side.
Negative durations — -P1D — are legal under ISO 8601-2 and supported by Temporal.Duration. Not supporting them is a real interoperability gap for anyone round-tripping through Temporal.
The comma-fraction-plus-offset defect documented above is the smallest and most annoying item on the list, and the one most likely to bite somebody parsing European exports.
Beyond the list, the interesting question is what happens as Temporal reaches every runtime. Safari is the last holdout. When it ships, the values layer is finally solved for JavaScript, and the syntax layer underneath becomes more visible, not less — because Temporal's input grammar is a strict subset of what real systems actually emit. The more precise the destination, the more you need something that can describe what arrived.
▲ ■ ● Time is the hardest primitive in software because everyone assumes it is easy. Treat it as a language, keep the grammar small, keep the lexer public, and let the layer above decide what it means. Explore the source on GitHub →
🔗 Related Reading
These sixteen pieces sit next to Temporal Parser: other compiler-shaped open-source projects that chose a grammar over a pattern, the protocols that carry timestamps between systems, the AI agents that now write those timestamps, and the Taskade surfaces that consume them. Read them for the wider context around this parser rather than a second install guide.
- History of Mermaid: diagrams as code — the same lex-then-parse move applied to diagrams
- What is FFmpeg? The open-source multimedia framework — grammars for containers instead of dates
- The history of Markdown — a small language that beat a big one
- History of CRDTs: how math beat the distributed systems problem — the other hard primitive, and why it needed theory
- History of WebSockets: how the web got real-time — protocol design under backward-compatibility pressure
- The history of the Model Context Protocol — what happens when a format becomes a standard
- System design explained — where parsing sits in an architecture
- How LLMs got hands: tool use and function calling — why agents emit machine formats at all
- What are AI agents? — the systems whose reliability depends on these strings
- The HyperCard moment: from Bill Atkinson to AI micro apps — making a hard thing feel simple
- Claude Shannon and the history of information theory — structure before meaning, the original version
- Free AI app builders — build the thing that consumes these timestamps
- AI HTML generators — the adjacent developer toolkit
- JavaScript code generator and regex generator — for the patterns you still legitimately need
- Python to JavaScript converter and TypeScript to JavaScript — for porting a parser you already wrote
- Calendar (wiki) and Google Calendar (wiki) — the surfaces that consume these strings
🐑 Before you go... the strings in this article are not an abstraction. They are what your calendar, your schedulers, and your agents actually exchange. If you want to see the layer above the parser, these are the places it shows up in a Taskade Genesis workspace:
- 📅 Calendar view: every project date, on one surface
- 🔗 Calendar feed integration: subscribe from Apple Calendar, Google Calendar, or Outlook
- 🤖 Custom AI agents: agents that read a document and return a deadline
- 🔄 Automation triggers: run a workflow on a schedule you describe in words
Building something that has to survive a timestamp? Create a free account and start from a prompt.
🔗 Resources
Every factual claim in this article traces to one of these fifteen sources. The repository, the npm package, and the playground are the primary sources for the library itself. RFC 3339, RFC 9557, and the ECMA-262 numbers-and-dates clause are the normative standards, and the Temporal proposal plus its parse draft are where the design decision at the heart of this article is recorded.
- https://github.com/taskade/temporal-parser
- https://www.npmjs.com/package/@taskade/temporal-parser
- https://lxcid.com/tools/temporal-playground/
- https://lxcid.com/2026/01/15/announcing-temporal-parser/
- https://www.rfc-editor.org/rfc/rfc3339.html
- https://www.rfc-editor.org/rfc/rfc9557.html
- https://tc39.es/ecma262/multipage/numbers-and-dates.html
- https://tc39.es/proposal-temporal/
- https://tc39.es/proposal-temporal/docs/
- https://tc39.es/proposal-temporal/docs/parse-draft.html
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse
- https://bugzilla.mozilla.org/show_bug.cgi?id=1274354
- https://caniuse.com/temporal
- https://benwilber.github.io/programming/2021/05/07/stop-parsing-iso8601-with-regex.html
- https://github.com/taskade/uri-parser
💬 Frequently Asked Questions About Temporal Parsing
These seventeen questions cover the library, the three standards it implements, why Date.parse is unreliable, why TC39 Temporal declines to ship a general parser, and the name collision with Temporal.io. The same answers ship in faq_schema so search engines receive a clean FAQPage. Each is written to survive being quoted on its own.
What is @taskade/temporal-parser?
It is an open-source TypeScript lexer and parser for ISO 8601, RFC 3339, and IXDTF temporal expressions. It turns a temporal string into a typed AST and stringifies that tree back. Zero runtime dependencies, ESM and CommonJS, full types, Node 20 or later, MIT licensed, 461 tests.
Why is Date.parse unreliable in JavaScript?
Because the standard says it may be. ECMA-262 clause 21.4.3.2 permits every engine to "fall back to any implementation-specific heuristics or implementation-specific date formats" for any string outside the one specified format, and states that the result is implementation-defined. Mozilla documents that 2014-02-30 returns a March timestamp in Chrome and NaN in Firefox.
Why does new Date('2025-01-12') show the previous day?
Because clause 21.4.3.2 specifies that a date-only form with no offset is interpreted as UTC, while a date-time form with no offset is interpreted as local time. In a UTC−8 zone, midnight UTC prints as 16:00 on the previous day. The behavior is specified, not a bug — it is just a rule almost nobody knows.
What is the difference between ISO 8601 and RFC 3339?
ISO 8601 is the broad language: week dates, ordinal dates, basic format, durations, intervals, comma or dot fractions. RFC 3339 (July 2002) is a narrow Internet profile: full date required, T required, offset required as Z or ±HH:MM. RFC 3339 also pins down one thing ISO 8601 leaves undefined — -00:00 meaning "UTC known, local offset unknown" — and restates the leap second :60 as explicitly legal.
What is IXDTF and RFC 9557?
IXDTF is the Internet Extended Date/Time Format, published as RFC 9557 in April 2024 on the Standards Track, updating RFC 3339. It adds optional bracketed suffixes to a timestamp: one IANA time-zone name and any number of [key=value] annotation tags. 2025-01-12T10:00:00+08:00[Asia/Singapore][u-ca=gregory] is IXDTF. Neither ISO 8601 nor RFC 3339 can name a zone.
What does the ! mean in [!u-ca=iso8601]?
It is the RFC 9557 critical flag. A consumer that does not understand a critical annotation must reject the entire string rather than ignore the annotation. A non-critical annotation may be ignored safely. The parser records the flag as a boolean on every annotation and time-zone node. A regex that strips brackets destroys it.
Why does TC39 Temporal not have a Temporal.parse()?
The parse draft says it "is not currently planned to be implemented." A strongly typed API conflicts with a parse-anything function, and the overflow and offset options already handle the two ambiguities such a function would have to surface. Temporal reached Stage 4 in March 2026 without one.
Which browsers support the Temporal API in 2026?
Firefox 139 shipped it first in May 2025, Chrome 144 in January 2026, Edge 144 shortly after, Node.js 26 in May 2026. Safari does not ship it on any release, only in Technology Preview, so global support is around 69% and Temporal is not yet Baseline.
Can you parse ISO 8601 with a regular expression?
A regex can recognize a shape; it cannot return a structure. ISO 8601 spans week dates, ordinal dates, basic format, durations, intervals, negative and expanded years, and two fractional separators, and RFC 9557 adds nested bracketed annotations with a criticality flag. Each pattern you add to cover one more form widens the surface for the next false positive.
How is Temporal Parser different from luxon or date-fns?
They operate on values you already have; this operates on the string you were handed. luxon and date-fns add days, format for a locale, and convert zones. Temporal Parser produces the typed tree you feed into them. Use both — they are different layers, not alternatives.
Does Temporal Parser validate calendar dates?
No, deliberately. 2025-02-31 returns a well-formed AST with day: 31, and so does 2025-13-45. Structure is not existence. Pass the fields to Temporal.PlainDate.from with { overflow: 'reject' } when you need a day that actually exists.
What does Temporal Parser not support?
Week dates (2025-W03), expanded years (+002025-01-12), and negative durations (-P1D) throw. Standalone ISO times (T10:30, 10:30:00) throw and are tracked as issue #13. Three inputs fail more quietly: 2025-012 returns month 12, 20250112 returns a year of 20250112, and 2025-02-31 returns day 31. Assert on the AST, not on the absence of a throw.
Is the lexer public?
Yes. lexTemporal returns the token stream and combineTimezoneOffsets folds a four-token offset into one. Every token carries character positions, so you can highlight, underline, or re-parse with your own grammar. The shipped parser is one consumer of that stream, not the only permitted one.
Does Temporal Parser have runtime dependencies?
None. The package declares no dependencies and no peerDependencies. It ships dual ESM and CommonJS builds through a conditional exports map with types first. Minified it measures about 12.3 KB, or roughly 4.2 KB minified and gzipped. The published dist is not minified, so the raw file is about 26 KB.
How do I install and import it?
npm install @taskade/temporal-parser, then import { parseTemporal } from '@taskade/temporal-parser'. There is no default export — a default import resolves to undefined under ESM and fails under verbatimModuleSyntax.
Is Temporal Parser related to Temporal.io?
No. Temporal.io is a durable workflow-orchestration platform. The TC39 Temporal API is the JavaScript standard for date and time values. Academic "temporal parsers" extract dates from prose. This library is a fourth thing: a lexer and parser for machine-readable date and time strings.
Who wrote it?
Stan Chang (@lxcid) designed and implemented Temporal Parser while building scheduling and calendar features at Taskade. The repository and npm scope are MIT-licensed under Taskade. His technical write-up is the author version; this page is the announcement, and they agree on the facts.
If you have a timestamp that should have parsed and did not, send it. The next useful test case is usually already sitting in a log file, a calendar export, or an agent tool result.






