Date Parsing and the UTC/Timezone Stack
Prerequisites
- ›Articles 1-2: Architecture and Plugin System
- ›JavaScript Date API (getFullYear vs getUTCFullYear, getTimezoneOffset)
- ›Basic understanding of Intl.DateTimeFormat
- ›Concepts of UTC, timezone offsets, and DST transitions
Date Parsing and the UTC/Timezone Stack
Time handling is where date libraries earn their keep—and where they accumulate their most subtle bugs. Day.js manages this complexity by layering three components: a core parser that handles local time, a UTC plugin that adds fixed-offset support, and a timezone plugin that resolves IANA zones through Intl.DateTimeFormat. Each layer builds on the one below it using the prototype-wrapping pattern we explored in Part 2. This article traces a date string through all three layers.
Core Parsing: parseDate() and REGEX_PARSE
As we saw in Part 1, parseDate() is the core's input normalization function. Let's look at it through the lens of its type-based dispatch:
flowchart TD
A[parseDate] --> B{null?}
B -->|Yes| C["new Date(NaN)<br/>— Invalid Date sentinel"]
B -->|No| D{undefined?}
D -->|Yes| E["new Date()<br/>— current time"]
D -->|No| F{instanceof Date?}
F -->|Yes| G["new Date(date)<br/>— defensive clone"]
F -->|No| H{string?}
H -->|Yes| I{ends with Z?}
I -->|Yes| J["new Date(date)<br/>— native parser handles TZ"]
I -->|No| K{REGEX_PARSE match?}
K -->|Yes| L["Component extraction<br/>UTC or local mode"]
K -->|No| J
H -->|No| J
The REGEX_PARSE pattern on constant.js line 29 is carefully designed to match partial date strings:
/^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/
It accepts 2024, 2024-01, 2024-01-15, 2024-01-15T10:30:00, and even 2024/01/15 10:30:00.123. But it rejects anything ending with Z or a timezone offset—the /Z$/i check on line 68 catches those first.
Why the deliberate rejection? When the core parses 2024-01-15T10:30:00, it needs to decide: is this local time or UTC? The answer depends on context. Without a timezone suffix, Day.js treats it as local time (using new Date(year, month, ...)) unless the utc flag is set in the config (using Date.UTC(...)). That UTC flag comes from the UTC plugin.
The millisecond extraction on line 72 is subtle: (d[7] || '0').substring(0, 3). The regex captures all trailing digits, which could be 123 (milliseconds), 1234 (microseconds), or more. Day.js truncates to 3 digits since JavaScript Date only supports millisecond precision.
The UTC Plugin: $u Flag and $offset Mechanism
The UTC plugin is 153 lines that fundamentally change how Day.js interacts with the Date object. It introduces two key pieces of state:
$u(boolean): When true, all Date accessors switch fromgetFullYear/getMonthtogetUTCFullYear/getUTCMonth$offset(number): A fixed UTC offset in minutes, enabling operations likedayjs().utcOffset('+05:30')
src/plugin/utc/index.js#L24-L69
The init() override is where $u has its most visible effect:
const oldInit = proto.init
proto.init = function () {
if (this.$u) {
const { $d } = this
this.$y = $d.getUTCFullYear()
this.$M = $d.getUTCMonth()
this.$D = $d.getUTCDate()
// ... all UTC accessors
} else {
oldInit.call(this)
}
}
When $u is true, the cached $-prefixed properties reflect UTC values instead of local time. This means format(), startOf(), and every other method that reads $y, $M, $D, etc. automatically operates in UTC—without any changes to their own code.
sequenceDiagram
participant User
participant UTC as utc plugin
participant Core as core
participant Date as native Date
User->>UTC: dayjs.utc('2024-06-15T10:00:00')
UTC->>Core: new Dayjs({ date, utc: true })
Core->>UTC: parse(cfg) — sets $u = true
UTC->>Core: oldParse.call(this, cfg)
Core->>Date: new Date(Date.UTC(2024, 5, 15, 10, 0, 0))
Core->>UTC: init()
UTC->>Date: getUTCFullYear(), getUTCMonth()...
Note over UTC: $y=2024, $M=5, $D=15, $H=10
UTC-->>User: Dayjs instance in UTC mode
The $offset mechanism is more complex. It enables fixed-offset behavior like dayjs().utcOffset('+05:30'), where the instance isn't truly UTC and isn't truly local—it's at a fixed offset. The valueOf() override compensates for this:
src/plugin/utc/index.js#L119-L123
proto.valueOf = function () {
const addedOffset = !this.$utils().u(this.$offset)
? this.$offset + (this.$x.$localOffset || this.$d.getTimezoneOffset()) : 0
return this.$d.valueOf() - (addedOffset * MILLISECONDS_A_MINUTE)
}
There's also an undocumented toDate('s') mode that the startOf() method uses internally:
src/plugin/utc/index.js#L137-L143
proto.toDate = function (type) {
if (type === 's' && this.$offset) {
return dayjs(this.format('YYYY-MM-DD HH:mm:ss:SSS')).toDate()
}
return oldToDate.call(this)
}
The 's' stands for "strip offset." When startOf() calls this.toDate(), it needs the Date object to represent the wall-clock time, not the UTC time. This round-trips through format() and dayjs() to create a local Date with the same displayed time—a hack, but one that works within the immutability constraint.
Tip: The core
startOf()method uses`set${this.$u ? 'UTC' : ''}`to dynamically choose betweensetHoursandsetUTCHours(line 163 ofindex.js). This is how UTC-awareness propagates through the core without the core knowing about the UTC plugin—it just checks$u.
The Timezone Plugin: IANA Zone Resolution
The timezone plugin adds named timezone support (e.g., America/New_York, Asia/Tokyo) on top of the UTC plugin. It's the most complex plugin in the ecosystem at ~157 lines, and it relies on Intl.DateTimeFormat for the heavy lifting.
The first thing it does is set up a cache:
src/plugin/timezone/index.js#L12-L34
const dtfCache = {}
const getDateTimeFormat = (timezone, options = {}) => {
const timeZoneName = options.timeZoneName || 'short'
const key = `${timezone}|${timeZoneName}`
let dtf = dtfCache[key]
if (!dtf) {
dtf = new Intl.DateTimeFormat('en-US', {
hour12: false,
timeZone: timezone,
// ... all components
})
dtfCache[key] = dtf
}
return dtf
}
The comment above the cache says it all: DateTimeFormat construction is "a very slow method." Creating one for each operation would be a performance disaster in hot paths.
The tzOffset() function uses formatToParts() to extract the UTC offset for a given timestamp in a given timezone:
src/plugin/timezone/index.js#L45-L67
It formats the timestamp as components, reconstructs a UTC time string from those components, parses it with dayjs.utc(), and compares the two timestamps. The difference is the timezone offset. This is clever—it lets the browser's Intl API handle the IANA database lookup while Day.js focuses on the math.
The fixOffset() Algorithm
The most subtle code in the timezone plugin is fixOffset(), adapted from Luxon:
src/plugin/timezone/index.js#L72-L91
flowchart TD
A["fixOffset(localTS, guessedOffset, tz)"] --> B["utcGuess = localTS - guessedOffset"]
B --> C["actualOffset = tzOffset(utcGuess, tz)"]
C --> D{guessedOffset === actualOffset?}
D -->|Yes| E["Return [utcGuess, guessedOffset]<br/>Offset was correct"]
D -->|No| F["Adjust: utcGuess -= (actual - guessed)"]
F --> G["thirdOffset = tzOffset(adjusted, tz)"]
G --> H{actualOffset === thirdOffset?}
H -->|Yes| I["Return [adjusted, actualOffset]<br/>DST transition found"]
H -->|No| J["In a 'hole' — spring-forward gap<br/>Return [localTS - min(offsets), max(offsets)]"]
This algorithm handles DST transitions. When clocks spring forward, there's a "hole"—a period that doesn't exist in local time (e.g., 2:00 AM to 3:00 AM). When clocks fall back, there's an ambiguity—the same local time occurs twice. fixOffset() resolves both cases with at most two offset lookups.
The algorithm works in three steps:
- Subtract the guessed offset from local time to get a UTC estimate
- Check what the actual offset is at that UTC time
- If they differ, adjust and check again. If they still differ, we're in a DST hole—pick the larger offset (standard time).
The Three Layers Working Together
Let's trace a real scenario: parsing "2024-03-10 02:30" in America/New_York. March 10, 2024 is spring-forward day—2:00 AM jumps to 3:00 AM.
flowchart TD
subgraph Layer1["Layer 3: timezone plugin"]
A["d.tz('2024-03-10 02:30', 'America/New_York')"] --> B["Parse as UTC: d.utc('2024-03-10 02:30')"]
B --> C["localTs = UTC value of 2024-03-10 02:30Z"]
C --> D["fixOffset(localTs, previousOffset, 'America/New_York')"]
D --> E["Detects DST hole<br/>Returns adjusted timestamp + offset"]
end
subgraph Layer2["Layer 2: utc plugin"]
B --> F["Sets $u = true"]
F --> G["parseDate uses Date.UTC()"]
E --> H["d(targetTs).utcOffset(targetOffset)"]
H --> I["Sets $offset, creates fixed-offset instance"]
end
subgraph Layer3["Layer 1: core"]
G --> J["new Date(Date.UTC(2024, 2, 10, 2, 30, 0))"]
I --> K["Cached $y, $M, $D, $H reflect offset-adjusted values"]
end
The core provides the Date construction. The UTC plugin provides UTC-mode parsing and fixed-offset instances. The timezone plugin provides the IANA zone resolution and DST handling. Each layer uses the one below via the prototype-wrapping pattern.
The set${this.$u ? 'UTC' : ''} pattern in the core's startOf() and $set() methods is the bridge that makes this work:
const utcPad = `set${this.$u ? 'UTC' : ''}`
The core doesn't import or reference the UTC plugin. It just checks this.$u—a property that doesn't exist until the UTC plugin sets it. This is duck-typing as architecture: the core cooperates with plugins it doesn't know about by checking for properties they might set.
customParseFormat: Building Parsers from Format Strings
The customParseFormat plugin is the most complex plugin in Day.js at ~270 lines. It compiles format strings like 'YYYY-MM-DD HH:mm' into parser functions that extract date components from input strings.
The expressions object maps format tokens to [regex, parser] pairs:
src/plugin/customParseFormat/index.js#L62-L129
For example:
YYYYmaps to[/\d{4}/, addInput('year')]—match 4 digits, store asyearDomaps to[/\d*[^-_:/,()\s\d]+/, ...]—match an ordinal like1st, iterate 1–31 through the locale's ordinal function to find which day matchesA/amaps to a meridiem matcher that handles locale-specific AM/PM strings
The makeParser() function compiles a format string into a reusable parser:
src/plugin/customParseFormat/index.js#L146-L179
flowchart TD
A["makeParser('YYYY-MM-DD HH:mm')"] --> B["Tokenize via formattingTokens regex"]
B --> C["['YYYY', '-', 'MM', '-', 'DD', ' ', 'HH', ':', 'mm']"]
C --> D["Map tokens to expressions"]
D --> E["YYYY → {regex: /\d{4}/, parser: addInput('year')}"]
D --> F["'-' → literal string '-'"]
D --> G["MM → {regex: /\d\d/, parser: addInput('month')}"]
E & F & G --> H["Return parser function"]
H --> I["Parser walks input string left-to-right<br/>matching regex, calling parsers"]
I --> J["{year: 2024, month: 1, day: 15, hours: 10, minutes: 30}"]
Strict mode (when the third argument is true) re-formats the parsed date and compares it to the original input. If they don't match—for example, parsing "2024-02-30" as a date produces "2024-03-01"—the result is marked invalid:
src/plugin/customParseFormat/index.js#L248-L250
if (isStrict && date != this.format(format)) {
this.$d = new Date('')
}
The plugin also supports multi-format parsing—pass an array of formats, and it tries each until one produces a valid date:
src/plugin/customParseFormat/index.js#L253-L265
Tip: When using
customParseFormatwith locales, load the locale before parsing. The plugin capturesthis.$locale()at parse time to resolve month names, meridiem strings, and ordinals. Changing the locale after parsing won't retroactively fix the parsed values.
What's Next
We've traced the full parsing pipeline from raw strings through core regex parsing, UTC mode switching, IANA timezone resolution, and format-string compilation. The three-layer architecture—core, UTC plugin, timezone plugin—demonstrates how Day.js achieves complex behavior through composition rather than configuration.
In the next article, we'll turn to internationalization: how Day.js supports 143 locales through a lazy-loading, self-registration architecture, and how plugins like relativeTime and localizedFormat consume locale data to produce culturally-appropriate output.