# Open Source Temporal Parser: Parse ISO 8601, RFC 3339, and IXDTF in TypeScript (2026)

> Date.parse guesses and Temporal ships no parser. @taskade/temporal-parser is a zero-dependency TypeScript lexer for ISO 8601, RFC 3339, and IXDTF strings.

- Source: https://www.taskade.com/blog/open-source-temporal-parser
- Published: 2026-09-10
- Updated: 2026-09-12
- Author: Taskade Team

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-parser` is 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 a `Date`, 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 →](https://github.com/taskade/temporal-parser)

![Grandfather clock with a calendar date dial beside the time face](https://www.taskade.com/static_images/blog/iso-8601-temporal-parser/clock-with-date.jpg)

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](https://tc39.es/proposal-temporal/docs/), luxon, or date-fns, after you already hold structured fields.

```mermaid
graph TD
  STR["a temporal string arrives"] --> L1["Layer 1 — SYNTAX<br/>what is this string made of?"]
  L1 --> L2["Layer 2 — VALUES<br/>is this a real instant?"]
  L2 --> L3["Layer 3 — ARITHMETIC<br/>what is this plus three days?"]
  L1 --- O1["@taskade/temporal-parser<br/>typed AST + exposed lexer"]
  L2 --- O2["TC39 Temporal<br/>PlainDate, ZonedDateTime"]
  L3 --- O3["luxon · date-fns · Day.js"]
  style STR fill:#1e1e2e,color:#94a3b8,stroke:#475569
  style L1 fill:#2a1225,color:#ff8fa3,stroke:#ff2d60,stroke-width:2px
  style O1 fill:#2a1225,color:#ff8fa3,stroke:#ff2d60,stroke-width:2px
  style L2 fill:#0d1b2a,color:#7dd3fc,stroke:#38bdf8,stroke-width:1.5px
  style O2 fill:#0d1b2a,color:#7dd3fc,stroke:#38bdf8,stroke-width:1.5px
  style L3 fill:#0d1a0e,color:#86efac,stroke:#22c55e,stroke-width:1.5px
  style O3 fill:#0d1a0e,color:#86efac,stroke:#22c55e,stroke-width:1.5px
```

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](https://github.com/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.2` tarball and the repository at `main`, 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](https://www.taskade.com/blog/ai-agents-history) 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](https://tc39.es/proposal-temporal/docs/parse-draft.html), which opens:

> "This is a draft design document for a `Temporal.parse` API, **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.

```mermaid
graph TD
  Q["How do I parse this date string?"] --> MDN["MDN: Date.parse is<br/>implementation-defined"]
  MDN --> T["MDN: ...which is why<br/>Temporal was introduced"]
  T --> PD["TC39 parse draft:<br/>Temporal.parse is not<br/>currently planned"]
  PD --> GAP{"So who parses<br/>the string?"}
  GAP -->|"the old answer"| RX["a pile of regular expressions"]
  GAP -->|"this library"| TP["lexer → parser → typed AST"]
  style Q fill:#1e1e2e,color:#94a3b8,stroke:#475569
  style MDN fill:#1a140a,color:#ffb74d,stroke:#e65100,stroke-width:1.5px
  style T fill:#1a140a,color:#ffb74d,stroke:#e65100,stroke-width:1.5px
  style PD fill:#1a0d0d,color:#ef9a9a,stroke:#b71c1c,stroke-width:1.5px
  style GAP fill:#1e1e2e,color:#94a3b8,stroke:#475569
  style RX fill:#1a0d0d,color:#ef9a9a,stroke:#b71c1c,stroke-width:1.5px
  style TP fill:#2a1225,color:#ff8fa3,stroke:#ff2d60,stroke-width:2px
```

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 neighbor'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.

```mermaid
graph TD
  IN["input string"] --> C{"conforms to<br/>Date Time String Format?"}
  C -->|yes| SPEC["specified path<br/>deterministic across engines"]
  C -->|no| H{"engine heuristics<br/>ECMA-262 21.4.3.2"}
  H -->|"V8 / SpiderMonkey"| PERM["maximally permissive<br/>invents a plausible date"]
  H -->|"JavaScriptCore"| STRICT["stricter<br/>more NaN"]
  PERM --> DIV["engines disagree<br/>implementation-defined"]
  STRICT --> DIV
  SPEC --> Z{"UTC offset present?"}
  Z -->|"no, date-only"| UTC["interpreted as UTC"]
  Z -->|"no, date-time"| LOC["interpreted as LOCAL"]
  Z -->|yes| OK["unambiguous instant"]
  style IN fill:#1e1e2e,color:#94a3b8,stroke:#475569
  style SPEC fill:#0d1a0e,color:#86efac,stroke:#22c55e,stroke-width:1.5px
  style OK fill:#0d1a0e,color:#86efac,stroke:#22c55e,stroke-width:1.5px
  style H fill:#1a140a,color:#ffb74d,stroke:#e65100,stroke-width:1.5px
  style PERM fill:#1a0d0d,color:#ef9a9a,stroke:#b71c1c,stroke-width:1.5px
  style STRICT fill:#1a0d0d,color:#ef9a9a,stroke:#b71c1c,stroke-width:1.5px
  style DIV fill:#1a0d0d,color:#ef9a9a,stroke:#b71c1c,stroke-width:1.5px
  style UTC fill:#0d1b2a,color:#7dd3fc,stroke:#38bdf8,stroke-width:1.5px
  style LOC fill:#2a1225,color:#ff8fa3,stroke:#ff2d60,stroke-width:2px
```

### 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"](https://benwilber.github.io/programming/2021/05/07/stop-parsing-iso8601-with-regex.html), 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 |

```mermaid
graph LR
  A["1988<br/>ISO 8601<br/>the language"] --> B["2002<br/>RFC 3339<br/>the Internet profile"]
  B --> C["2019<br/>ISO 8601-1/-2<br/>the split"]
  C --> D["2021<br/>Temporal Stage 3<br/>values, not parsing"]
  D --> E["Apr 2024<br/>RFC 9557 / IXDTF<br/>named zones + calendars"]
  E --> F["Mar 2026<br/>Temporal Stage 4<br/>still no parse()"]
  F --> G["2026<br/>this parser<br/>the syntax layer"]
  style A fill:#1e1e2e,color:#94a3b8,stroke:#475569
  style B fill:#1e1e2e,color:#94a3b8,stroke:#475569
  style C fill:#0d1b2a,color:#7dd3fc,stroke:#38bdf8,stroke-width:1.5px
  style D fill:#11111b,color:#cdd6f4,stroke:#00d4aa
  style E fill:#1a0d24,color:#ce93d8,stroke:#6a1b9a,stroke-width:1.5px
  style F fill:#11111b,color:#cdd6f4,stroke:#00d4aa
  style G fill:#2a1225,color:#ff8fa3,stroke:#ff2d60,stroke-width:2px
```

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

```mermaid
graph TD
  S["one IXDTF timestamp<br/>with a zone and a calendar tag"] --> O["-08:00<br/>OFFSET<br/>where the clock was"]
  S --> Z["America/Los_Angeles<br/>ZONE<br/>which rules apply later"]
  S --> C["u-ca=hebrew<br/>CALENDAR<br/>how the date is written"]
  O --> D["a Date holds only this"]
  Z --> A["an AST holds all three"]
  C --> A
  O --> A
  style S fill:#1e1e2e,color:#94a3b8,stroke:#475569
  style O fill:#0d1b2a,color:#7dd3fc,stroke:#38bdf8,stroke-width:1.5px
  style Z fill:#1a0d24,color:#ce93d8,stroke:#6a1b9a,stroke-width:1.5px
  style C fill:#1a140a,color:#ffb74d,stroke:#e65100,stroke-width:1.5px
  style D fill:#1a0d0d,color:#ef9a9a,stroke:#b71c1c,stroke-width:1.5px
  style A fill:#2a1225,color:#ff8fa3,stroke:#ff2d60,stroke-width:2px
```

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.

![Prague Astronomical Clock showing layered time, calendar, and astronomical faces on one machine](https://www.taskade.com/static_images/blog/iso-8601-temporal-parser/prague-astronomical-clock.jpg)

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](https://tc39.es/proposal-temporal/) 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 })` returns `23: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.

```mermaid
graph TD
  S["temporal string"] --> L["lexTemporal"]
  L --> T["token stream"]
  T --> C["combineTimezoneOffsets"]
  C --> P["parseTemporal"]
  P --> A["typed AST"]
  A --> ST["stringifyTemporal"]
  ST --> OUT["canonical string"]
  T --> CUSTOM["your own grammar"]
  A --> TMP["Temporal.PlainDate.from<br/>with overflow: reject"]
  style S fill:#1e1e2e,color:#94a3b8,stroke:#475569
  style A fill:#2a1225,color:#ff8fa3,stroke:#ff2d60,stroke-width:2px
  style L fill:#11111b,color:#cdd6f4,stroke:#00d4aa
  style P fill:#11111b,color:#cdd6f4,stroke:#00d4aa
  style C fill:#0d1b2a,color:#7dd3fc,stroke:#38bdf8,stroke-width:1.5px
  style ST fill:#0d1b2a,color:#7dd3fc,stroke:#38bdf8,stroke-width:1.5px
  style T fill:#1e1e2e,color:#94a3b8,stroke:#475569
  style CUSTOM fill:#1a140a,color:#ffb74d,stroke:#e65100,stroke-width:1.5px
  style OUT fill:#0d1a0e,color:#86efac,stroke:#22c55e,stroke-width:1.5px
  style TMP fill:#0d1a0e,color:#86efac,stroke:#22c55e,stroke-width:1.5px
```

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](https://www.taskade.com/blog/history-of-mermaid) made for diagrams, [Markdown](https://www.taskade.com/blog/markdown-history) made for prose, and [FFmpeg](https://www.taskade.com/blog/ffmpeg-history) 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](https://www.taskade.com/blog/history-of-computing) 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:

```mermaid
stateDiagram-v2
  [*] --> Start
  Start --> Year: digits
  Start --> DurationBody: P
  Year --> Month: dash
  Month --> Day: dash
  Year --> Done: end of input
  Month --> Done: end of input
  Day --> TimePart: T designator
  Day --> Done: end of input
  TimePart --> OffsetPart: Z or sign
  TimePart --> Annotations: left bracket
  OffsetPart --> Annotations: left bracket
  OffsetPart --> Done: end of input
  Annotations --> Annotations: another bracket group
  Annotations --> Done: end of input
  DurationBody --> Done: end of input
  Done --> [*]
```

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.

```mermaid
graph TD
  T["TemporalAst<br/>union"] --> R["RangeAst<br/>kind: Range"]
  T --> V["ValueAst<br/>union"]
  V --> DT["DateTimeAst<br/>kind: DateTime"]
  V --> DU["DurationAst<br/>kind: Duration"]
  R -.->|"start / end, either may be null"| V
  DT --> D["DateAst<br/>year, month?, day?"]
  DT --> TI["TimeAst<br/>hour, minute, second?, fraction?"]
  DT --> OF["OffsetAst<br/>UtcOffset or NumericOffset"]
  DT --> TZ["TimeZoneAst<br/>id, critical"]
  DT --> AN["AnnotationAst<br/>raw, critical, pairs"]
  DU --> AN
  style T fill:#2a1225,color:#ff8fa3,stroke:#ff2d60,stroke-width:2px
  style V fill:#2a1225,color:#ff8fa3,stroke:#ff2d60,stroke-width:2px
  style R fill:#11111b,color:#cdd6f4,stroke:#00d4aa
  style DT fill:#11111b,color:#cdd6f4,stroke:#00d4aa
  style DU fill:#11111b,color:#cdd6f4,stroke:#00d4aa
  style D fill:#0d1b2a,color:#7dd3fc,stroke:#38bdf8,stroke-width:1.5px
  style TI fill:#0d1b2a,color:#7dd3fc,stroke:#38bdf8,stroke-width:1.5px
  style OF fill:#1a0d24,color:#ce93d8,stroke:#6a1b9a,stroke-width:1.5px
  style TZ fill:#1a0d24,color:#ce93d8,stroke:#6a1b9a,stroke-width:1.5px
  style AN fill:#1a140a,color:#ffb74d,stroke:#e65100,stroke-width:1.5px
```

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.

```mermaid
erDiagram
  RANGE ||--o| DATETIME : "start or end"
  RANGE ||--o| DURATION : "start or end"
  DATETIME ||--|| DATE : has
  DATETIME ||--o| TIME : "has optional"
  DATETIME ||--o| OFFSET : "has optional"
  DATETIME ||--o| TIMEZONE : "has optional"
  DATETIME ||--o{ ANNOTATION : carries
  DURATION ||--o{ ANNOTATION : carries
  DATE {
    number year
    number month_optional
    number day_optional
  }
  TIME {
    number hour
    number minute
    number second_optional
    string fraction_optional
  }
  OFFSET {
    string kind_Utc_or_Numeric
    string sign
    number hours
    number minutes
  }
  TIMEZONE {
    string id
    boolean critical
  }
  ANNOTATION {
    string raw
    boolean critical
    object pairs
  }
```

---

## ✅ 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.

```mermaid
sequenceDiagram
  participant App
  participant Lexer
  participant Parser
  participant AST
  participant Stringifier
  App->>Lexer: "2025-01-12T10:00:00+0530"
  Lexer->>Parser: 16 tokens, offset folded to 13
  Parser->>AST: DateTimeAst with NumericOffset
  AST-->>App: date, time, offset, zone, annotations
  App->>Stringifier: the same AST, unmodified
  Stringifier-->>App: "2025-01-12T10:00:00+05:30"
  Note over App,Stringifier: parse → stringify → parse again;<br/>the second AST must match the first
```

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.

1. **Structure is not existence.** `2025-02-31` is a well-formed string and not a civil day. Leap days, month lengths, and daylight-saving gaps are decided after the AST.
2. **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.
3. **Watch the three silent misparses.** `2025-012`, `20250112`, and `2025-02-31` return an AST. Assert on the fields you expect, not on the absence of a throw.
4. **`parseTemporal` has no default export.** Use `import { parseTemporal } from '@taskade/temporal-parser'`. A default import resolves to `undefined` under ESM and breaks outright under `verbatimModuleSyntax`.
5. **Standalone ISO times are not supported yet.** A bare `T10:30` or `10:30:00` throws in `parseTemporal`. Use `parseTimeString` for human forms, and track [issue #13](https://github.com/taskade/temporal-parser/issues/13).
6. **`parseOffset` does not accept `Z`.** It throws `Offset must start with + or -`. Only `parseTemporal` produces the `UtcOffset` variant. This one surprises people who read the return type first.
7. **`parseOffset` is looser than either standard.** It accepts `+5:30` with 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 the `hours` field if you need strictness.
8. **Offset and zone can disagree.** `+08:00` is 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.
9. **A duration without a start instant is not a length of time.** `P1M` is one month. One month after 31 January is a question this library will not answer and should not.
10. **Feeding the AST into Temporal needs `{ overflow: 'reject' }`.** The AST is a property bag, and the property-bag path defaults to `constrain`. Without that option, 31 February becomes 28 February and nothing tells you.
11. **The hyphen is overloaded.** In `-0044-03-15` the leading `-` is a year sign. In `2025-01-01/2025-12-31` the `/` is a range separator, not division. Do not split on punctuation.

If a string fails and you believe it should not, [open an issue](https://github.com/taskade/temporal-parser/issues) 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.

```mermaid
xychart-beta horizontal
  title "npm weekly downloads by layer, September 2026"
  x-axis [date-fns, dayjs, luxon, moment, js-joda, temporal-polyfill, chrono-node, iso-validator]
  y-axis "Millions of weekly downloads" 0 --> 70
  bar [69.5, 51.8, 28.1, 25.7, 3.2, 2.6, 1.5, 0.15]
```

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*.

```mermaid
graph TD
  START["What is in your hand?"] --> Q1{"A string, or a value?"}
  Q1 -->|"a machine-format string"| Q2{"Do you need its structure?"}
  Q1 -->|"a Date or Temporal value"| ARITH["luxon · date-fns · Day.js<br/>or Temporal arithmetic"]
  Q1 -->|"human prose"| NLP["chrono-node<br/>natural language"]
  Q2 -->|"yes — fields, zone, annotations"| TP["@taskade/temporal-parser"]
  Q2 -->|"no — just a valid instant"| TMP["Temporal.Instant.from<br/>or ZonedDateTime.from"]
  TP --> AST["typed AST"]
  AST --> TMP
  TMP --> ARITH
  style START fill:#1e1e2e,color:#94a3b8,stroke:#475569
  style Q1 fill:#1e1e2e,color:#94a3b8,stroke:#475569
  style Q2 fill:#1e1e2e,color:#94a3b8,stroke:#475569
  style TP fill:#2a1225,color:#ff8fa3,stroke:#ff2d60,stroke-width:2px
  style AST fill:#2a1225,color:#ff8fa3,stroke:#ff2d60,stroke-width:2px
  style TMP fill:#0d1b2a,color:#7dd3fc,stroke:#38bdf8,stroke-width:1.5px
  style ARITH fill:#0d1a0e,color:#86efac,stroke:#22c55e,stroke-width:1.5px
  style NLP fill:#1a0d24,color:#ce93d8,stroke:#6a1b9a,stroke-width:1.5px
```

---

## 🏗️ 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](https://www.taskade.com/agents) 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](https://www.taskade.com/learn/agents/agent-tools), an [automation trigger](https://www.taskade.com/learn/automation/triggers), or a [prompt](https://www.taskade.com/prompts) 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.

```mermaid
sequenceDiagram
  participant Agent as AI Agent
  participant Flow as Automation
  participant TP as parseTimeString
  participant Sched as Scheduler
  Agent->>Flow: "run this every day at 9:07 AM"
  Flow->>TP: "9:07 AM"
  alt parses
    TP-->>Flow: { kind: Time, hour: 9, minute: 7 }
    Flow->>Sched: register daily trigger
    Sched-->>Agent: scheduled ✓
  else throws
    TP-->>Flow: ParseError with token index
    Flow-->>Agent: "Invalid time format. Expected 14:30:00 or 9:07 AM"
    Note over Agent,Flow: the agent gets a correctable error,<br/>not a silently wrong instant
  end
```

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 valid

Temporal.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](https://lxcid.com/tools/temporal-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.

[Try the playground →](https://lxcid.com/tools/temporal-playground/)

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](https://lxcid.com/2026/01/15/announcing-temporal-parser/) 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`](https://github.com/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](https://github.com/taskade/temporal-parser) |
| **npm** | [`@taskade/temporal-parser`](https://www.npmjs.com/package/@taskade/temporal-parser) |
| **Playground** | [lxcid.com/tools/temporal-playground](https://lxcid.com/tools/temporal-playground/) |
| **Author write-up** | [Stan Chang's announcement](https://lxcid.com/2026/01/15/announcing-temporal-parser/) |
| **License** | MIT |
| **Sibling** | [`@taskade/uri-parser`](https://github.com/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](https://www.taskade.com/learn/projects/create-project), [calendar view](https://www.taskade.com/learn/projects/calendar-view), and the [Taskade calendar](https://www.taskade.com/learn/projects/taskade-calendar) itself. [Google Calendar sync](https://www.taskade.com/learn/connect/calendar-sync), the [Google Calendar integration](https://www.taskade.com/learn/connect/google-calendar), and [calendar feeds](https://www.taskade.com/learn/connect/calendar-feed) pass ISO strings in both directions with Apple Calendar, Google Calendar, and Outlook. [Automations](https://www.taskade.com/automate) fire on due dates and on schedules a human or a model described in words. [Custom AI agents](https://www.taskade.com/learn/agents/custom-agents) extract deadlines from documents and hand them to a runtime that has to act on them. [Templates](https://www.taskade.com/templates) bake timestamps into sample content that must still parse a year later, and the apps in the [community gallery](https://www.taskade.com/apps) inherit every one of those strings.

```mermaid
graph TD
  M["▲ MEMORY<br/>projects, notes,<br/>calendar entries"] --> I["■ INTELLIGENCE<br/>agents read a document<br/>and emit a timestamp"]
  I --> P["temporal-parser<br/>string → typed AST"]
  P --> E["● EXECUTION<br/>automations fire,<br/>events are created"]
  E --> M
  P -.->|"ParseError with<br/>a token index"| I
  style M fill:#0d1b2a,color:#7dd3fc,stroke:#38bdf8,stroke-width:1.5px
  style I fill:#1a0d24,color:#ce93d8,stroke:#6a1b9a,stroke-width:1.5px
  style P fill:#2a1225,color:#ff8fa3,stroke:#ff2d60,stroke-width:2px
  style E fill:#0d1a0e,color:#86efac,stroke:#22c55e,stroke-width:1.5px
```

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](https://www.taskade.com/docs/getting-started/overview) and the [/docs](https://www.taskade.com/docs) hub. The [hosted MCP](https://www.taskade.com/docs/mcp/hosted-mcp) surface is a separate open-source line — connect a workspace to your editor — and Temporal Parser does not depend on it.

> ▲ ■ ●&nbsp; 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 →](https://www.taskade.com/create)

---

## 🔮 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](https://github.com/taskade/temporal-parser/issues/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.

> ▲ ■ ●&nbsp; 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 →](https://github.com/taskade/temporal-parser)

---

## 🔗 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](https://www.taskade.com/blog/history-of-mermaid) — the same lex-then-parse move applied to diagrams
- [What is FFmpeg? The open-source multimedia framework](https://www.taskade.com/blog/ffmpeg-history) — grammars for containers instead of dates
- [The history of Markdown](https://www.taskade.com/blog/markdown-history) — a small language that beat a big one
- [History of CRDTs: how math beat the distributed systems problem](https://www.taskade.com/blog/crdt-history) — the other hard primitive, and why it needed theory
- [History of WebSockets: how the web got real-time](https://www.taskade.com/blog/websockets-history) — protocol design under backward-compatibility pressure
- [The history of the Model Context Protocol](https://www.taskade.com/blog/mcp-protocol-history) — what happens when a format becomes a standard
- [System design explained](https://www.taskade.com/blog/ai-system-design) — where parsing sits in an architecture
- [How LLMs got hands: tool use and function calling](https://www.taskade.com/blog/tool-use-history) — why agents emit machine formats at all
- [What are AI agents?](https://www.taskade.com/blog/what-are-ai-agents) — the systems whose reliability depends on these strings
- [The HyperCard moment: from Bill Atkinson to AI micro apps](https://www.taskade.com/blog/hypercard-atkinson-history) — making a hard thing feel simple
- [Claude Shannon and the history of information theory](https://www.taskade.com/blog/claude-shannon-history) — structure before meaning, the original version
- [Free AI app builders](https://www.taskade.com/blog/free-ai-app-builders) — build the thing that consumes these timestamps
- [AI HTML generators](https://www.taskade.com/blog/ai-html-generators) — the adjacent developer toolkit
- [JavaScript code generator](https://www.taskade.com/generate/programming/javascript) and [regex generator](https://www.taskade.com/generate/programming/regex) — for the patterns you still legitimately need
- [Python to JavaScript converter](https://www.taskade.com/convert/coding/python-to-javascript) and [TypeScript to JavaScript](https://www.taskade.com/convert/coding/typescript-to-javascript-2) — for porting a parser you already wrote
- [Calendar (wiki)](https://www.taskade.com/wiki/taskade/calendar) and [Google Calendar (wiki)](https://www.taskade.com/wiki/integrations/google-calendar) — 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](https://www.taskade.com/create) workspace:
>
> - 📅 [**Calendar view**](https://www.taskade.com/learn/projects/calendar-view): every project date, on one surface
> - 🔗 [**Calendar feed integration**](https://www.taskade.com/learn/connect/calendar-feed): subscribe from Apple Calendar, Google Calendar, or Outlook
> - 🤖 [**Custom AI agents**](https://www.taskade.com/learn/agents/custom-agents): agents that read a document and return a deadline
> - 🔄 [**Automation triggers**](https://www.taskade.com/learn/automation/triggers): run a workflow on a schedule you describe in words
>
> Building something that has to survive a timestamp? [Create a free account](https://www.taskade.com/signup) 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.

1. https://github.com/taskade/temporal-parser
2. https://www.npmjs.com/package/@taskade/temporal-parser
3. https://lxcid.com/tools/temporal-playground/
4. https://lxcid.com/2026/01/15/announcing-temporal-parser/
5. https://www.rfc-editor.org/rfc/rfc3339.html
6. https://www.rfc-editor.org/rfc/rfc9557.html
7. https://tc39.es/ecma262/multipage/numbers-and-dates.html
8. https://tc39.es/proposal-temporal/
9. https://tc39.es/proposal-temporal/docs/
10. https://tc39.es/proposal-temporal/docs/parse-draft.html
11. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse
12. https://bugzilla.mozilla.org/show_bug.cgi?id=1274354
13. https://caniuse.com/temporal
14. https://benwilber.github.io/programming/2021/05/07/stop-parsing-iso8601-with-regex.html
15. 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](https://tc39.es/proposal-temporal/docs/parse-draft.html) 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](https://github.com/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](https://lxcid.com/2026/01/15/announcing-temporal-parser/) 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.

[![Taskade AI banner.](https://www.taskade.com/static_images/blog/taskade-ai-banner-768x198-1.png)](https://www.taskade.com/create)
