A lexer and parser for ISO 8601, RFC 3339, and IXDTF temporal expressions, built with compiler design principles.
14
stars
23
commits
TypeScript
primary language
Sep 10, 2026
updated
A lexer and parser for ISO 8601, RFC 3339, and IXDTF temporal expressions, built with compiler design principles.
By Stan Chang (@lxcid).
Try it: interactive playground · write-up: announcing @taskade/temporal-parser · sibling: @taskade/uri-parser
This library lexes and parses a temporal string into a typed AST (and stringifies it back). It does not do date math, and it does not validate calendar ranges.
| Need | Use |
|---|---|
| ISO 8601 / RFC 3339 / IXDTF / durations / ranges → AST | this package |
| Date/time values, arithmetic, calendars | TC39 Temporal (PlainDate.from, …). Temporal has no general parse() for these forms — feed it fields from the AST |
Format, locale, “add 3 days” on a Date | luxon or date-fns |
npm install @taskade/temporal-parser
import { parseTemporal } from '@taskade/temporal-parser';
// Parse a complete datetime with timezone
const result = parseTemporal('2025-01-12T10:00:00+08:00[Asia/Singapore]');
console.log(result);
// {
// 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: []
// }
// Parse a duration
const duration = parseTemporal('P1Y2M3DT4H5M6S');
// { kind: 'Duration', years: 1, months: 2, days: 3, hours: 4, minutes: 5, seconds: 6, ... }
// Parse a date range
const range = parseTemporal('2025-01-01/2025-12-31');
// { kind: 'Range', start: {...}, end: {...} }
// Parse BC dates (negative years in ISO 8601)
const bcDate = parseTemporal('-0044-03-15');
// { kind: 'DateTime', date: { year: -44, month: 3, day: 15 }, ... }
20252025-012025-01-12-0044-03-15 (44 BC), 0000-01-01 (1 BC)T10:30T10:30:45T10:30:45.123456789T10:30:45,123 (normalized to dot in output)Z+08:00, -05:30, +0530, +09[Asia/Singapore], [America/New_York]P1Y2M3D (1 year, 2 months, 3 days)PT4H5M6S (4 hours, 5 minutes, 6 seconds)P1Y2M3DT4H5M6SPT1.5S or PT1,5S (comma normalized to dot)[u-ca=gregory][!u-ca=iso8601]2025-01-12[u-ca=gregory][u-tz=UTC]2025-01-01/2025-12-31/2025-12-312025-01-01/2025-01-01/P1Yimport { lexTemporal, combineTimezoneOffsets } from '@taskade/temporal-parser';
// Tokenize a temporal string
const tokens = lexTemporal('2025-01-12T10:00:00+08:00');
// Optionally combine timezone offset tokens
const combined = combineTimezoneOffsets(tokens);
import { parseOffset } from '@taskade/temporal-parser';
const offset = parseOffset('+08:00');
// { kind: 'NumericOffset', sign: '+', hours: 8, minutes: 0, raw: '+08:00' }
import { parseTimeString } from '@taskade/temporal-parser';
// Parse 12-hour format with AM/PM
const time1 = parseTimeString('2:30 PM');
// { kind: 'Time', hour: 14, minute: 30 }
// Parse 24-hour format (international)
const time2 = parseTimeString('14:30');
// { kind: 'Time', hour: 14, minute: 30 }
// Parse with seconds
const time3 = parseTimeString('2:30:45 PM');
// { kind: 'Time', hour: 14, minute: 30, second: 45 }
// Parse with fractional seconds
const time4 = parseTimeString('14:30:45.123');
// { kind: 'Time', hour: 14, minute: 30, second: 45, fraction: '123' }
// Flexible AM/PM formats
parseTimeString('2:30 PM'); // Standard
parseTimeString('2:30PM'); // No space
parseTimeString('2:30 pm'); // Lowercase
parseTimeString('2:30 p.m.'); // With periods
// Special times
parseTimeString('12:00 AM'); // Midnight (hour: 0)
parseTimeString('12:00 PM'); // Noon (hour: 12)
parseTimeString('11:59 PM'); // End of day (hour: 23)
import { parseTemporal, stringifyTemporal } from '@taskade/temporal-parser';
// Parse and stringify
const ast = parseTemporal('2025-01-12T10:00:00+08:00[Asia/Singapore]');
const str = stringifyTemporal(ast);
// '2025-01-12T10:00:00+08:00[Asia/Singapore]'
// Offsets are normalized to canonical format (±HH:MM)
const ast2 = parseTemporal('2025-01-12T10:00:00+0530'); // Compact format
const str2 = stringifyTemporal(ast2);
// '2025-01-12T10:00:00+05:30' (normalized)
// Stringify individual components
import { stringifyDate, stringifyTime, stringifyDuration } from '@taskade/temporal-parser';
stringifyDate({ kind: 'Date', year: 2025, month: 1, day: 12 });
// '2025-01-12'
stringifyTime({ kind: 'Time', hour: 10, minute: 30, second: 45 });
// '10:30:45'
stringifyDuration({ kind: 'Duration', years: 1, months: 2, raw: 'P1Y2M', annotations: [] });
// 'P1Y2M'
Time is one of the most complex human inventions. Leap years, calendars, time zones, daylight saving rules, cultural conventions—every attempt to model time exposes exceptions and edge cases. Even today, we still struggle to write correct and maintainable code for something as fundamental as dates and times.
Despite its wide adoption, ISO 8601 / RFC 3339 is incomplete. It lacks proper support for time zones beyond numeric offsets, forcing real-world systems to rely on extensions such as IXDTF (inspired by Java's ZonedDateTime). Unfortunately, only very recent tools—and the latest generation of LLMs—have begun to meaningfully understand these formats.
In JavaScript and TypeScript, temporal parsing remains especially difficult. No single data structure can fully represent time. Instead, we are left with a wide variety of string representations, each with different semantics and assumptions.
Even the TC39 community explicitly chose not to fully solve parsing when designing the Temporal API, acknowledging the scope and complexity of the problem. (See: https://tc39.es/proposal-temporal/docs/parse-draft.html)
And yet, time remains one of the most important concepts for human productivity and coordination.
This project tackles the problem head-on.
This repository treats temporal parsing as a compiler problem.
Instead of relying on fragile regexes or opinionated parsers, we apply classic compiler techniques—lexing and parsing—to temporal strings. Our goal is not to impose a single "correct" interpretation of time, but to make the structure of temporal expressions explicit and programmable.
What makes this project different is that we intentionally expose the lexer.
If the provided parser does not match your needs, you are free to:
In other words, this project does not claim to "solve time." It gives you the tools to reason about it.
parseTemporal(input: string): TemporalAstMain parser function that accepts an ISO 8601 / IXDTF string and returns an AST.
Returns: One of:
DateTimeAst - A datetime value with optional timezone and annotationsDurationAst - A duration value (P...)RangeAst - A range between two valuesThrows: ParseError if the input is invalid.
lexTemporal(input: string): Token[]Tokenizes the input string into a stream of tokens.
combineTimezoneOffsets(tokens: Token[]): AnyToken[]Post-processes tokens to combine timezone offset components into single tokens.
parseOffset(offsetString: string, position?: number): OffsetAstParses a numeric timezone offset string.
Supported formats:
+08:00, -05:30+0530, -0800+09, -05Valid ranges:
parseTimeString(input: string): TimeAstParses a standalone time string in various formats.
Supported formats:
2:30 PM, 02:30PM, 2:30 p.m.14:30, 02:30, 23:592:30:45 PM, 14:30:452:30:45.123 PM, 14:30:45,123 (comma or dot)Special cases:
12:00 AM → midnight (hour: 0)12:00 PM → noon (hour: 12)12:30 AM → 00:30 (after midnight)12:30 PM → 12:30 (after noon)Returns: TimeAst object compatible with Temporal.PlainTime.from()
Throws: ParseError if the input is invalid
stringifyTemporal(ast: TemporalAst): stringConverts a temporal AST back to its string representation.
Returns: ISO 8601 / IXDTF formatted string
Also available:
stringifyDate(date: DateAst): stringstringifyTime(time: TimeAst): stringstringifyDateTime(dateTime: DateTimeAst): stringstringifyDuration(duration: DurationAst): stringstringifyRange(range: RangeAst): stringstringifyOffset(offset: OffsetAst): stringstringifyTimeZone(timeZone: TimeZoneAst): stringstringifyAnnotation(annotation: AnnotationAst): stringFull TypeScript definitions are included. All AST types are exported:
import type {
TemporalAst,
DateTimeAst,
DurationAst,
RangeAst,
DateAst,
TimeAst,
OffsetAst,
TimeZoneAst,
AnnotationAst,
} from '@taskade/temporal-parser';
@taskade/uri-parser — same compiler approach for URIs (RFC 3986)See CONTRIBUTING.md for development setup and guidelines.
MIT © Taskade
TypeScript
98.5%
JavaScript
1.5%
A lexer and parser for ISO 8601, RFC 3339, and IXDTF temporal expressions, built with compiler design principles.
14
stars
23
commits
TypeScript
primary language
Sep 10, 2026
updated
A lexer and parser for ISO 8601, RFC 3339, and IXDTF temporal expressions, built with compiler design principles.
By Stan Chang (@lxcid).
Try it: interactive playground · write-up: announcing @taskade/temporal-parser · sibling: @taskade/uri-parser
This library lexes and parses a temporal string into a typed AST (and stringifies it back). It does not do date math, and it does not validate calendar ranges.
| Need | Use |
|---|---|
| ISO 8601 / RFC 3339 / IXDTF / durations / ranges → AST | this package |
| Date/time values, arithmetic, calendars | TC39 Temporal (PlainDate.from, …). Temporal has no general parse() for these forms — feed it fields from the AST |
Format, locale, “add 3 days” on a Date | luxon or date-fns |
npm install @taskade/temporal-parser
import { parseTemporal } from '@taskade/temporal-parser';
// Parse a complete datetime with timezone
const result = parseTemporal('2025-01-12T10:00:00+08:00[Asia/Singapore]');
console.log(result);
// {
// 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: []
// }
// Parse a duration
const duration = parseTemporal('P1Y2M3DT4H5M6S');
// { kind: 'Duration', years: 1, months: 2, days: 3, hours: 4, minutes: 5, seconds: 6, ... }
// Parse a date range
const range = parseTemporal('2025-01-01/2025-12-31');
// { kind: 'Range', start: {...}, end: {...} }
// Parse BC dates (negative years in ISO 8601)
const bcDate = parseTemporal('-0044-03-15');
// { kind: 'DateTime', date: { year: -44, month: 3, day: 15 }, ... }
20252025-012025-01-12-0044-03-15 (44 BC), 0000-01-01 (1 BC)T10:30T10:30:45T10:30:45.123456789T10:30:45,123 (normalized to dot in output)Z+08:00, -05:30, +0530, +09[Asia/Singapore], [America/New_York]P1Y2M3D (1 year, 2 months, 3 days)PT4H5M6S (4 hours, 5 minutes, 6 seconds)P1Y2M3DT4H5M6SPT1.5S or PT1,5S (comma normalized to dot)[u-ca=gregory][!u-ca=iso8601]2025-01-12[u-ca=gregory][u-tz=UTC]2025-01-01/2025-12-31/2025-12-312025-01-01/2025-01-01/P1Yimport { lexTemporal, combineTimezoneOffsets } from '@taskade/temporal-parser';
// Tokenize a temporal string
const tokens = lexTemporal('2025-01-12T10:00:00+08:00');
// Optionally combine timezone offset tokens
const combined = combineTimezoneOffsets(tokens);
import { parseOffset } from '@taskade/temporal-parser';
const offset = parseOffset('+08:00');
// { kind: 'NumericOffset', sign: '+', hours: 8, minutes: 0, raw: '+08:00' }
import { parseTimeString } from '@taskade/temporal-parser';
// Parse 12-hour format with AM/PM
const time1 = parseTimeString('2:30 PM');
// { kind: 'Time', hour: 14, minute: 30 }
// Parse 24-hour format (international)
const time2 = parseTimeString('14:30');
// { kind: 'Time', hour: 14, minute: 30 }
// Parse with seconds
const time3 = parseTimeString('2:30:45 PM');
// { kind: 'Time', hour: 14, minute: 30, second: 45 }
// Parse with fractional seconds
const time4 = parseTimeString('14:30:45.123');
// { kind: 'Time', hour: 14, minute: 30, second: 45, fraction: '123' }
// Flexible AM/PM formats
parseTimeString('2:30 PM'); // Standard
parseTimeString('2:30PM'); // No space
parseTimeString('2:30 pm'); // Lowercase
parseTimeString('2:30 p.m.'); // With periods
// Special times
parseTimeString('12:00 AM'); // Midnight (hour: 0)
parseTimeString('12:00 PM'); // Noon (hour: 12)
parseTimeString('11:59 PM'); // End of day (hour: 23)
import { parseTemporal, stringifyTemporal } from '@taskade/temporal-parser';
// Parse and stringify
const ast = parseTemporal('2025-01-12T10:00:00+08:00[Asia/Singapore]');
const str = stringifyTemporal(ast);
// '2025-01-12T10:00:00+08:00[Asia/Singapore]'
// Offsets are normalized to canonical format (±HH:MM)
const ast2 = parseTemporal('2025-01-12T10:00:00+0530'); // Compact format
const str2 = stringifyTemporal(ast2);
// '2025-01-12T10:00:00+05:30' (normalized)
// Stringify individual components
import { stringifyDate, stringifyTime, stringifyDuration } from '@taskade/temporal-parser';
stringifyDate({ kind: 'Date', year: 2025, month: 1, day: 12 });
// '2025-01-12'
stringifyTime({ kind: 'Time', hour: 10, minute: 30, second: 45 });
// '10:30:45'
stringifyDuration({ kind: 'Duration', years: 1, months: 2, raw: 'P1Y2M', annotations: [] });
// 'P1Y2M'
Time is one of the most complex human inventions. Leap years, calendars, time zones, daylight saving rules, cultural conventions—every attempt to model time exposes exceptions and edge cases. Even today, we still struggle to write correct and maintainable code for something as fundamental as dates and times.
Despite its wide adoption, ISO 8601 / RFC 3339 is incomplete. It lacks proper support for time zones beyond numeric offsets, forcing real-world systems to rely on extensions such as IXDTF (inspired by Java's ZonedDateTime). Unfortunately, only very recent tools—and the latest generation of LLMs—have begun to meaningfully understand these formats.
In JavaScript and TypeScript, temporal parsing remains especially difficult. No single data structure can fully represent time. Instead, we are left with a wide variety of string representations, each with different semantics and assumptions.
Even the TC39 community explicitly chose not to fully solve parsing when designing the Temporal API, acknowledging the scope and complexity of the problem. (See: https://tc39.es/proposal-temporal/docs/parse-draft.html)
And yet, time remains one of the most important concepts for human productivity and coordination.
This project tackles the problem head-on.
This repository treats temporal parsing as a compiler problem.
Instead of relying on fragile regexes or opinionated parsers, we apply classic compiler techniques—lexing and parsing—to temporal strings. Our goal is not to impose a single "correct" interpretation of time, but to make the structure of temporal expressions explicit and programmable.
What makes this project different is that we intentionally expose the lexer.
If the provided parser does not match your needs, you are free to:
In other words, this project does not claim to "solve time." It gives you the tools to reason about it.
parseTemporal(input: string): TemporalAstMain parser function that accepts an ISO 8601 / IXDTF string and returns an AST.
Returns: One of:
DateTimeAst - A datetime value with optional timezone and annotationsDurationAst - A duration value (P...)RangeAst - A range between two valuesThrows: ParseError if the input is invalid.
lexTemporal(input: string): Token[]Tokenizes the input string into a stream of tokens.
combineTimezoneOffsets(tokens: Token[]): AnyToken[]Post-processes tokens to combine timezone offset components into single tokens.
parseOffset(offsetString: string, position?: number): OffsetAstParses a numeric timezone offset string.
Supported formats:
+08:00, -05:30+0530, -0800+09, -05Valid ranges:
parseTimeString(input: string): TimeAstParses a standalone time string in various formats.
Supported formats:
2:30 PM, 02:30PM, 2:30 p.m.14:30, 02:30, 23:592:30:45 PM, 14:30:452:30:45.123 PM, 14:30:45,123 (comma or dot)Special cases:
12:00 AM → midnight (hour: 0)12:00 PM → noon (hour: 12)12:30 AM → 00:30 (after midnight)12:30 PM → 12:30 (after noon)Returns: TimeAst object compatible with Temporal.PlainTime.from()
Throws: ParseError if the input is invalid
stringifyTemporal(ast: TemporalAst): stringConverts a temporal AST back to its string representation.
Returns: ISO 8601 / IXDTF formatted string
Also available:
stringifyDate(date: DateAst): stringstringifyTime(time: TimeAst): stringstringifyDateTime(dateTime: DateTimeAst): stringstringifyDuration(duration: DurationAst): stringstringifyRange(range: RangeAst): stringstringifyOffset(offset: OffsetAst): stringstringifyTimeZone(timeZone: TimeZoneAst): stringstringifyAnnotation(annotation: AnnotationAst): stringFull TypeScript definitions are included. All AST types are exported:
import type {
TemporalAst,
DateTimeAst,
DurationAst,
RangeAst,
DateAst,
TimeAst,
OffsetAst,
TimeZoneAst,
AnnotationAst,
} from '@taskade/temporal-parser';
@taskade/uri-parser — same compiler approach for URIs (RFC 3986)See CONTRIBUTING.md for development setup and guidelines.
MIT © Taskade
TypeScript
98.5%
JavaScript
1.5%