This guide is the source-based internals reference for Day.js: how the core engine is structured, how the plugin system rewrites prototype methods at runtime, how parsing and formatting actually work, and what to know to build, test, secure, and contribute to the library. It is written for maintainers, integrators embedding Day.js deep in a toolchain, and architects evaluating it as a Moment.js replacement.
For end-user API usage, see the companion User Guide.
Table of Contents
- Executive Summary
- Architecture Overview
- Component Inventory
- The Core Engine
- The Plugin System
- The Parsing Pipeline
- The Formatting Pipeline
- Immutability and the Wrapper Model
- The Locale System
- Requirements
- Build and Release Pipeline
- Testing Strategy
- Security Analysis
- Code Quality and Patterns
- Internal Field Reference
- Potential Improvements and Tech Debt
- Contributing
- Quick Reference
1. Executive Summary
Day.js is a small, immutable date-time library that wraps the native JavaScript Date object and exposes a Moment.js-compatible API. Its defining architectural choices are:
- A tiny, fixed core. The entire core is three files —
src/constant.js,src/utils.js,src/index.js— totaling roughly 14 KB of source and enforced to ship under 2.99 KB minified+gzipped by asize-limitbudget. - Zero runtime dependencies. Every entry in
package.jsonis adevDependency. Nothing is pulled into a consumer's bundle except Day.js itself. - Everything optional is a plugin. Relative time, UTC, time zones, durations, custom parsing, ISO weeks, and 30+ more capabilities are delivered as 37 plugins that monkey-patch the prototype at runtime via
dayjs.extend(). - Immutability by default. Manipulation methods return new instances through a single
wrapper()factory. Mutation is available only through the deliberately-namedbadMutableplugin. - On-demand i18n. 143 locale files ship with the package; only English is bundled into the core, and other locales register themselves on import.
| Attribute | Value |
|---|---|
| Language | JavaScript (ES2015+), authored in src/, transpiled with Babel |
| Type definitions | Bundled (types/index.d.ts + per-plugin .d.ts) |
| Runtime dependencies | None |
| Core size budget | 2.99 KB (dayjs.min.js, gzipped) |
| Plugins | 37 official plugins under src/plugin/ |
| Locales | 143 files under src/locale/ |
| License | MIT |
| Module formats | UMD (dayjs.min.js), ESM (esm/) |
| Test runner | Jest, 100% line-coverage threshold |
| Release | Automated via semantic-release |
2. Architecture Overview
┌─────────────────────────────────────────┐
│ Consumer application │
│ import dayjs from 'dayjs' │
│ dayjs.extend(plugin) │
└───────────────────┬───────────────────────┘
│
┌────────────────────────────────┼────────────────────────────────┐
│ CORE (src/, ~2KB, no deps) │
│ │
│ ┌──────────────┐ ┌────────────┐ ┌──────────────────────┐ │
│ │ constant.js │ │ utils.js │ │ index.js │ │
│ │ time units, │──▶│ s,z,m,a,p,u│──▶│ dayjs() factory │ │
│ │ regexes, │ │ (1-letter │ │ class Dayjs │ │
│ │ FORMAT_DEF. │ │ aliases) │ │ wrapper(), parse, │ │
│ └──────────────┘ └────────────┘ │ format, diff, … │ │
│ │ Ls{} locale registry│ │
│ built-in ───▶ │ en locale │ │
│ └──────────┬───────────┘ │
└───────────────────────────────────────────────── │ ───────────────┘
│ dayjs.extend()
┌────────────────────────────────────────┼───────────────────┐
│ │ │
┌────────▼─────────┐ ┌─────────▼────────┐ ┌───────▼────────┐
│ PLUGINS (37) │ │ LOCALES (143) │ │ TYPES │
│ src/plugin/* │ │ src/locale/* │ │ types/*.d.ts │
│ monkey-patch │ │ register into Ls │ │ augment module │
│ Dayjs.prototype │ │ on import │ │ 'dayjs' │
│ or dayjs.* static │ │ │ │ │
└───────────────────┘ └───────────────────┘ └─────────────────┘
The core never imports a plugin or a non-English locale. Dependencies flow strictly inward: index.js depends on utils.js and constant.js; plugins and locales depend on the public surface the core exposes to them at extend/import time. This is what keeps the core's size constant regardless of how many plugins exist.
Data flow for a typical call
dayjs('2019-01-25')
→ factory: not a Dayjs, build cfg { date, args }
→ new Dayjs(cfg)
→ parseLocale(cfg.locale) sets $L
→ parse(cfg) → parseDate(cfg) → native Date → $d
→ init() caches $y,$M,$D,$W,$H,$m,$s,$ms
→ .add(1, 'day')
→ computes next timestamp
→ wrapper(ts, this) → dayjs(ts, {locale,$u,$x,$offset})
→ NEW Dayjs instance (original untouched)
→ .format('YYYY-MM-DD')
→ REGEX_FORMAT.replace with matches() lookup
→ string
3. Component Inventory
| Path | Role |
|---|---|
src/constant.js |
Time-unit milliseconds, unit string constants, FORMAT_DEFAULT, parse/format regexes, INVALID_DATE_STRING |
src/utils.js |
Shared utility object with single-letter keys consumed by core and plugins |
src/index.js |
dayjs() factory, Dayjs class, wrapper(), locale registry, extend(), static helpers |
src/locale/*.js |
143 locale definition modules (English built into the core) |
src/plugin/*/index.js |
37 plugin modules, each a (option, Dayjs, dayjs) => void function |
src/plugin/localizedFormat/utils.js |
Shared helpers (u, t, englishFormats) used by localizedFormat, localeData, customParseFormat |
types/index.d.ts |
Core TypeScript declarations and the PluginFunc contract |
types/plugin/*.d.ts |
Per-plugin declarations that augment the dayjs module |
types/locale/*.d.ts |
Locale module declarations |
test/*.test.js |
Core feature tests |
test/plugin/*.test.js |
One test file per plugin |
test/locale/*.test.js |
Locale-specific tests |
test/issues/*.test.js |
Regression tests pinned to GitHub issues |
build/ |
Rollup/Babel build scripts (referenced by npm run build) |
docs/ |
Multilingual documentation site source (en, zh-cn, ja, ko, pt-br, es-es, ru, fr, he, si, sw, tr) |
Note
The build directory and bundler config (
karma.sauce.conf.js,babel.config.js,prettier.config.js,.eslintrc.json,.releaserc) live at the repo root. The published artifacts (dayjs.min.js,esm/, individual plugin/locale files) are generated, not committed tosrc/.
4. The Core Engine
src/constant.js — the vocabulary
This module defines everything the rest of the library treats as fixed:
- Millisecond constants —
MILLISECONDS_A_SECOND(1e3) throughMILLISECONDS_A_WEEK, derived fromSECONDS_A_*. Used byadd,diff, and thedurationplugin. - Unit string constants —
MS,S,MIN,H,D,W,M,Q,Y,DATE. These are the canonical internal unit names ('millisecond','second', …'date'). Every method normalizes incoming units to one of these. FORMAT_DEFAULT='YYYY-MM-DDTHH:mm:ssZ'— the template used whenformat()is called with no argument.INVALID_DATE_STRING='Invalid Date'— the sentinelisValid()checks against.REGEX_PARSE— matches ISO 8601-like strings into capture groups (year, month, day, hour, minute, second, milliseconds).REGEX_FORMAT— matches format tokens (and bracketed escapes) duringformat().
src/utils.js — the toolbox
A single default-exported object whose keys are one letter each, to minimize minified size. These are attached to the shared Utils object and reused by every plugin:
| Key | Function | Purpose |
|---|---|---|
s |
padStart |
Left-pad numbers (zero-padding for MM, DD, …) |
z |
padZoneStr |
Build the ±HH:mm zone string for the Z token |
m |
monthDiff |
Month difference, ported verbatim from Moment.js for parity |
a |
absFloor |
Floor toward zero (used by diff) |
p |
prettyUnit |
Normalize a unit alias to its canonical constant |
u |
isUndefined |
x === undefined check (used everywhere as a guard) |
Important
The single-letter naming is a deliberate size optimization, not obfuscation. Plugins reference these as
this.$utils().s(...),utils.p(...), etc. When reading plugin source, keep this table handy —uis "is undefined,"pis "pretty unit,"sis "pad start."
At load time, index.js augments this object with three more functions that need closure over core internals:
Utils.l = parseLocale— locale resolutionUtils.i = isDayjs— type predicateUtils.w = wrapper— the immutable instance factory
src/index.js — the engine
This file defines:
dayjs(date, c)— the factory. Ifdateis already a Day.js object it returns a clone; otherwise it builds a config object ({ date, args }, merging any options inc) and constructs aDayjs.class Dayjs— the instance type. The constructor resolves the locale, parses the date, seeds the$xextension bag, and tags the instance with$isDayjsObject.wrapper(date, instance)— creates a newdayjscarrying forwardlocale,utc($u), extension data ($x), and$offset. This is the single chokepoint through which all immutable results are produced.parseDate(cfg)— the core parser (see §6).- The locale registry — module-scoped
L(current global locale name, defaults to'en') andLs(a map of name → locale object), seeded with the built-in English locale. - Getter/setter generation — an array maps internal fields (
$y,$M, …) to unit constants and defines each prototype accessor through the shared$ghelper, soyear(),month(), etc. are generated rather than hand-written. - Static surface —
dayjs.extend,dayjs.locale,dayjs.isDayjs,dayjs.unix,dayjs.en,dayjs.Ls, anddayjs.p(a plugin-flag registry).
The Dayjs prototype methods grouped by concern:
| Concern | Methods |
|---|---|
| Lifecycle | constructor, parse, init, clone |
| Validity | isValid |
| Get/Set | $g, $set, set, get, generated unit accessors, daysInMonth |
| Manipulate | add, subtract, startOf, endOf |
| Display | format, utcOffset, diff, toDate, toJSON, toISOString, toString, valueOf, unix |
| Compare | isSame, isBefore, isAfter |
| Locale | $locale, locale |
| Plugin hook | $utils |
5. The Plugin System
The plugin system is the heart of Day.js's "small core, opt-in everything" design.
The contract
A plugin is a function with this TypeScript signature:
export type PluginFunc<T = unknown> =
(option: T, c: typeof Dayjs, d: typeof dayjs) => void
It receives three arguments:
option— the optional second argument passed todayjs.extend(plugin, option).c— theDayjsclass (so the plugin can patchc.prototype).d— thedayjsfactory (so the plugin can add static methods or construct new instances).
Installation — dayjs.extend
dayjs.extend = (plugin, option) => {
if (!plugin.$i) { // install only once
plugin(option, Dayjs, dayjs)
plugin.$i = true
}
return dayjs
}
Important
The
plugin.$iflag guards against double installation — callingextendwith the same plugin twice is a no-op. Because the flag lives on the plugin function object, it is process-global. This makesextendidempotent but also means a plugin's patches persist for the lifetime of the module.
How plugins extend behavior
Plugins use three techniques, often in combination:
1. Add a new prototype method:
// isLeapYear
export default (o, c) => {
c.prototype.isLeapYear = function () {
return ((this.$y % 4 === 0) && (this.$y % 100 !== 0)) || (this.$y % 400 === 0)
}
}
2. Wrap (decorate) an existing method:
// quarterOfYear extending add()
const oldAdd = proto.add
proto.add = function (number, units) {
const unit = this.$utils().p(units)
if (unit === Q) return this.add(number * 3, M)
return oldAdd.bind(this)(number, units) // delegate to the original
}
This "save old, define new, delegate to old" pattern is how customParseFormat, utc, objectSupport, arraySupport, bigIntSupport, negativeYear, badMutable, isoWeek, timezone, and others layer onto core methods like parse, format, startOf, add, and diff.
3. Add a static method to the factory:
// minMax
d.max = function () { /* … */ }
d.min = function () { /* … */ }
Statics added this way include dayjs.duration/isDuration, dayjs.max/min, dayjs.utc, dayjs.tz, dayjs.updateLocale, dayjs.months/weekdays, and dayjs.isMoment.
The extension data bag — $x
Plugins that need to attach per-instance state use the $x object, which wrapper() propagates across immutable operations. For example, the timezone plugin stores $x.$timezone, and utc stores $x.$localOffset. This is the sanctioned way to add instance state without new top-level fields.
The plugin flag registry — dayjs.p
dayjs.p is an empty object the core exposes so plugins can advertise their presence to one another. customParseFormat sets d.p.customParseFormat = true; the devHelper plugin reads that flag to decide whether to warn about format-string parsing.
Method-override ordering matters
Because plugins wrap methods in install order, the sequence of extend calls can change behavior when two plugins patch the same method.
Warning
Several plugins patch the same core methods (
parse,format,startOf,add,diff). When two such plugins interact, extend order determines wrapping order. Known hard dependencies:timezonerequiresutc;weekYearrequiresweekOfYear;isoWeeksInYearrequiresisLeapYear;duration.humanize()requiresrelativeTime. Establish a single, orderedextendblock at startup.
Plugin catalog
| Plugin | Technique | Patches / Adds |
|---|---|---|
advancedFormat |
wrap | format (extra tokens) |
arraySupport |
wrap | parse (array input) |
badMutable |
wrap | $g, set, startOf, add, locale, daysInMonth, isSame/Before/After → mutate in place |
bigIntSupport |
wrap | parse, dayjs.unix |
buddhistEra |
wrap | format (BBBB/BB) |
calendar |
add | calendar() |
customParseFormat |
wrap | parse (format strings, strict mode, format arrays) |
dayOfYear |
add | dayOfYear() |
devHelper |
wrap | parse, diff, dayjs.locale (dev warnings) |
duration |
add + wrap | dayjs.duration, isDuration; add/subtract accept durations |
isBetween |
add | isBetween() |
isLeapYear |
add | isLeapYear() |
isMoment |
add | dayjs.isMoment |
isoWeek |
add + wrap | isoWeek*, startOf('isoWeek') |
isoWeeksInYear |
add | isoWeeksInYear() |
isSameOrAfter / isSameOrBefore |
add | comparison helpers |
isToday / isTomorrow / isYesterday |
add | relative-day checks |
localeData |
add | instance + static localeData(), months, weekdays* |
localizedFormat |
wrap | format (L/LL/LLLL) |
minMax |
add | dayjs.max, dayjs.min |
negativeYear |
wrap | parse (negative-year strings) |
objectSupport |
wrap | parse, set, add, subtract (object input) |
pluralGetSet |
add | plural accessor aliases |
preParsePostFormat |
wrap | parse, format, fromToBase (locale hooks) |
quarterOfYear |
add + wrap | quarter(), 'quarter' in add/startOf |
relativeTime |
add | from, to, fromNow, toNow, fromToBase |
timezone |
add + wrap | tz, offsetName, startOf; dayjs.tz* |
toArray / toObject |
add | serializers |
updateLocale |
add | dayjs.updateLocale |
utc |
add + wrap | dayjs.utc; parse, init, utcOffset, format, valueOf, diff, toDate, … |
weekday |
add | weekday() |
weekOfYear |
add | week(), weeks() |
weekYear |
add | weekYear() |
6. The Parsing Pipeline
The core parseDate(cfg) resolves input to a native Date in this order:
null→new Date(NaN)(explicitly invalid).undefined→new Date()(now).Dateinstance →new Date(date)(defensive copy).- String without a trailing
Z→ matched againstREGEX_PARSE. On a match, components are extracted (month is decremented becauseDatemonths are zero-indexed; milliseconds are truncated to three digits) and a local or UTCDateis constructed. - Everything else (numbers,
Z-suffixed strings, unmatched strings) →new Date(date), delegating to the native engine.
const parseDate = (cfg) => {
const { date, utc } = cfg
if (date === null) return new Date(NaN)
if (Utils.u(date)) return new Date()
if (date instanceof Date) return new Date(date)
if (typeof date === 'string' && !/Z$/i.test(date)) {
const d = date.match(C.REGEX_PARSE)
if (d) { /* build Date from capture groups */ }
}
return new Date(date)
}
Caution
Step 5 is the source of cross-engine inconsistency: an unmatched string falls through to
new Date(string), whose parsing of non-ISO formats is implementation-defined. This is exactly the gap thecustomParseFormatplugin closes by replacingparseentirely for format-string input.
Plugin-extended parsing
Each parse-extending plugin wraps proto.parse, transforms cfg.date (or replaces $d directly), and delegates to the original:
arraySupport— array →DateviaDate.UTC.applyor theDateconstructor.objectSupport—{ year, month, … }→Date.bigIntSupport— BigInt →Number.negativeYear— leading-year strings.customParseFormat— tokenized format parsing with optional strict validation and format-array fallthrough.utc— sets the$uflag and switchesinit()togetUTC*accessors.
After parseDate, init() caches the broken-down components ($y, $M, $D, $W, $H, $m, $s, $ms) so accessors are field reads, not repeated Date method calls.
7. The Formatting Pipeline
format(formatStr) works as follows:
- If the instance is invalid, return the locale's
invalidDateor'Invalid Date'. - Default the template to
FORMAT_DEFAULT(YYYY-MM-DDTHH:mm:ssZ). - Compute the zone string with
Utils.z. - Run
formatStr.replace(REGEX_FORMAT, …). The replacer:- Returns bracketed escapes (
[literal]) verbatim. - Looks up each token in the
matches()switch (YY,YYYY,M…SSS,Z). - Falls back to the zone string with the colon stripped (the
ZZcase).
- Returns bracketed escapes (
Name-based tokens (MMM, MMMM, dd, ddd, dddd, A/a) read from the active locale, with getShort slicing full names when a locale omits explicit short/min variants. The meridiem defaults to AM/PM when the locale supplies no meridiem function.
Format-extending plugins wrap format and pre-process the template before delegating to the core:
localizedFormatexpandsL/LL/LLLL(and lowercase variants) using the locale'sformatsmap and the English fallbacks inlocalizedFormat/utils.js.advancedFormathandlesQ,Do,X,x,w/W,k,z, etc., then calls the oldformat.buddhistErasubstitutesBBBB/BB.preParsePostFormatruns the locale'spostformaton the result (for non-Latin numerals).utcchanges the default template toYYYY-MM-DDTHH:mm:ss[Z]in UTC mode.
Note
Because format plugins wrap rather than replace, layering (e.g.
localizedFormat+advancedFormat+buddhistEra) composes: each plugin transforms the template or output and hands off to the next-innerformat.
8. Immutability and the Wrapper Model
Immutability is enforced structurally, not by freezing objects. Every method that produces a "changed" date routes through wrapper():
const wrapper = (date, instance) =>
dayjs(date, {
locale: instance.$L,
utc: instance.$u,
x: instance.$x,
$offset: instance.$offset
})
wrapper(newTimestamp, this) constructs a brand-new Dayjs that inherits the originating instance's locale, UTC flag, extension bag, and offset. The original is never touched.
add,subtract,startOf,endOf,set,clone, and the unit setters all returnwrapper(...)results.$set(the private setter) mutates a clone, neverthisdirectly in the public path —set()callsthis.clone().$set(...).
Important
The
badMutableplugin breaks this contract intentionally. It redefinesset,startOf,add,locale, etc. to assign back tothis.$dand callthis.init(), returningthis. This is the only supported way to get Moment.js-style mutation, and it should be confined to migration code.
There is a known internal wart here: the $offset field carried by wrapper is annotated in source with // todo: refactor; do not use this.$offset in you code. See §16.
9. The Locale System
Registry and resolution
Two module-scoped values in index.js hold all locale state:
L— the current global locale name ('en'by default).Ls— a map of locale name → locale object, seeded with English.
parseLocale(preset, object, isLocal) is the single resolution function:
- With no preset, returns the current global
L. - With a string preset, lowercases it, registers
objectif provided, and falls back to the language part of alang-regioncode if the full code isn't loaded (e.g.en-xyz→en). - With an object preset, registers it under its
name. - Unless
isLocalis set, it updates the globalL.
This is exposed three ways: dayjs.locale() (global get/set), instance.locale() (per-object, always local — returns a clone), and Utils.l for plugin use.
Locale object shape
A locale module default-exports an object. The built-in English locale is intentionally minimal:
// src/locale/en.js
export default {
name: 'en',
weekdays: 'Sunday_Monday_…_Saturday'.split('_'),
months: 'January_…_December'.split('_'),
ordinal: (n) => { /* 1st, 2nd, 3rd, … */ }
}
Richer locales add weekdaysShort, weekdaysMin, monthsShort, weekStart, yearStart, formats (for localizedFormat), relativeTime (for relativeTime), meridiem, and calendar. English omits the short/min name arrays because format() derives them by slicing the full names when explicit variants are absent.
Note
Non-English locale files
import dayjs from 'dayjs'and calldayjs.locale(name, localeObject, true)on load, registering themselves intoLswithout changing the global default. That self-registration on import is whyimport 'dayjs/locale/zh-cn'is sufficient to make the locale available.
Mutating locales
The updateLocale plugin merges a partial config into an already-registered locale, deep-merging object-valued keys (like formats) and replacing others. It returns undefined if the locale isn't loaded.
Caution
updateLocalemutates the shared locale object inLsin place. Changes are global and persist for the process. Treat locale tweaks as application-startup configuration, not per-request state.
10. Requirements
Runtime (consumers)
| Requirement | Detail |
|---|---|
| JavaScript engine | Any environment with a working native Date. The README states all browsers are supported. |
Intl.DateTimeFormat |
Required only for the timezone plugin (IANA zone math and offsetName). |
BigInt |
Required only for the bigIntSupport plugin. |
| Module system | UMD build works as a global, CommonJS require, or AMD; ESM build is published under esm/. |
| Runtime dependencies | None. |
Development (contributors)
| Tool | Source of truth |
|---|---|
| Node.js + npm | Standard; the toolchain is npm-script driven |
| Babel | @babel/cli, @babel/core, @babel/preset-env — transpiles src/ |
| Rollup | rollup + rollup-plugin-babel + rollup-plugin-terser — bundles dayjs.min.js |
| Jest | jest + babel-jest — tests with coverage |
| ESLint | eslint + eslint-config-airbnb-base + plugins |
| Prettier | prettier — formats docs/**/*.md |
| size-limit | enforces the 2.99 KB budget |
| TypeScript | typescript — validates the bundled .d.ts files |
| Karma + Sauce | cross-browser test matrix |
| MockDate, moment, moment-timezone | test fixtures and parity comparisons |
11. Build and Release Pipeline
All build orchestration lives in package.json scripts:
| Script | Command | Purpose |
|---|---|---|
build |
cross-env BABEL_ENV=build node build && npm run size |
Bundle the UMD dayjs.min.js, then check size |
babel |
cross-env BABEL_ENV=build babel src --out-dir esm --copy-files && node build/esm |
Produce the ESM build under esm/ |
size |
size-limit && gzip-size dayjs.min.js |
Enforce the 2.99 KB budget |
test |
Multi-timezone Jest run + 100% line-coverage gate | Correctness across zones |
test-tz |
date && jest test/timezone.test --coverage=false |
Timezone suite under a forced TZ |
lint |
eslint src/* test/* build/* |
Style and correctness linting |
prettier |
prettier --write "docs/**/*.md" |
Format documentation |
sauce / test:sauce |
karma start karma.sauce.conf.js |
Cross-browser testing via Sauce Labs |
Size budget
"size-limit": [
{ "limit": "2.99 KB", "path": "dayjs.min.js" }
]
Important
The 2.99 KB ceiling on
dayjs.min.jsis a hard CI gate. Any core change that pushes the bundle over budget fails the build. This is the mechanism that keeps the "2 KB" promise honest — it is why new features are added as plugins rather than to the core.
Pre-commit and release
- A
pre-commithook runslintbefore every commit. - Releases are automated with semantic-release (
@semantic-release/github, configured via.releaserc). Version numbers, changelog entries, and GitHub releases are derived from conventional commit messages — notepackage.json'sversionis the placeholder"0.0.0-development"because the real version is computed at release time.
Note
The repo carries a local patch (
patches/@semantic-release+github+11.0.4.patch) applied to the release tooling. Patches like this are applied via the package manager's patch mechanism during install.
12. Testing Strategy
Day.js holds a 100% line-coverage threshold and tests every public surface.
"jest": {
"roots": ["test"],
"testRegex": "test/(.*?/)?.*test.js$",
"collectCoverage": true,
"collectCoverageFrom": ["src/**/*"]
}
"test": "cross-env TZ=Pacific/Auckland npm run test-tz
&& cross-env TZ=Europe/London npm run test-tz
&& cross-env TZ=America/Whitehorse npm run test-tz
&& npm run test-tz
&& jest --coverage --coverageThreshold=\"{ global: { lines: 100 } }\""
Test organization
| Suite | Coverage |
|---|---|
test/constructor, test/parse |
Object creation and parsing |
test/get-set, test/manipulate |
Accessors and manipulation |
test/display, test/comparison, test/query |
Formatting and comparisons |
test/locale + test/locale/* |
Locale behavior (per-locale files) |
test/timezone |
Time-zone math (run under multiple TZ values) |
test/plugin/* |
One file per plugin (39 plugin test files, including declarations.test.js and utc-utcOffset.test.js) |
test/issues/* |
Regression tests pinned to specific GitHub issues |
test/utils |
Utility functions |
test/__mocks__/dayjs.js |
Jest mock for the library |
Cross-timezone discipline
Important
The full test command runs the timezone suite four times under different
TZenvironment variables (Pacific/Auckland, Europe/London, America/Whitehorse, and the host default). Date libraries are notoriously sensitive to the host zone and DST rules; this matrix is how Day.js catches off-by-one and DST regressions before release. Runnpm test(not justjest) locally so you exercise the same matrix.
MockDate and moment parity
Tests use mockdate to freeze "now" deterministically, and import moment/moment-timezone as oracles to assert Moment.js-compatible output. When changing a method, the relevant moment-parity assertions are the bar to clear.
13. Security Analysis
Day.js is a pure computation library with a small and well-bounded attack surface.
| Area | Assessment |
|---|---|
| Network | None. Day.js makes no network calls. |
| Filesystem | None at runtime. |
eval / dynamic code |
None. No eval, Function(string), or dynamic require. |
| Runtime dependencies | Zero. No transitive supply-chain surface in production. |
| Secrets / auth | Not applicable; it is a client-side date utility. |
| Native API reliance | Built on Date and (for timezone) Intl.DateTimeFormat, both standard. |
Regular-expression exposure (ReDoS)
The parse and format regexes (REGEX_PARSE, REGEX_FORMAT, the customParseFormat formattingTokens, and the duration ISO regex) are applied to:
- Date input — the value a user might control.
REGEX_PARSEis anchored and bounded (fixed digit groups), so catastrophic backtracking is not expected on date strings. - Format strings — generally developer-authored constants, not user input.
Caution
Treat format strings as code, not data. Do not build a
format()orcustomParseFormattemplate from untrusted input — while the regexes themselves are bounded, passing attacker-controlled format templates is an unnecessary risk and can leak unexpected output. User input belongs in the date value argument, validated with strict-modecustomParseFormat+isValid().
Object-merge surfaces
updateLocale and objectSupport merge caller-supplied objects into internal structures. updateLocale spreads customConfig into the shared locale object.
Warning
Do not feed untrusted JSON directly into
dayjs.updateLocale()or as object input to the factory. Although these paths operate on locale/date fields rather than arbitrary prototypes, supplying unvalidated third-party objects to configuration APIs is a general prototype-pollution hygiene concern. Keep locale configuration developer-controlled.
Operational guidance
- Pin the Day.js version and rely on the lockfile; with zero runtime deps, an audit of Day.js is an audit of one package.
- Keep
devHelperout of production builds (it is auto-suppressed whenNODE_ENV === 'production', but excluding it entirely is cleaner). - Validate all user-entered dates with strict parsing before use.
14. Code Quality and Patterns
Recurring patterns
- Decorator method-wrapping —
const old = proto.x; proto.x = function(...) { … old.bind(this)(...) }. The backbone of the plugin system; used consistently across parse/format/manipulate extensions. - Single-factory immutability — all new instances flow through
wrapper(), guaranteeing locale/UTC/offset propagation in one place. - Unit normalization — every unit argument passes through
prettyUnit(Utils.p), so long, short, and plural forms converge to one canonical constant before branching. - Generated accessors — unit getters/setters are produced from a data table rather than written out, reducing surface area and size.
- Size-driven minimalism — one-letter utility keys, no helper libraries, English-only core. Readability is traded for bytes deliberately and consistently.
- Moment parity by porting —
monthDiffis copied from Moment.js specifically to reproduce its results; comparisons in tests assert against moment.
Conventions
- ESLint airbnb-base with targeted
eslint-disablecomments where the size/parity goals conflict with default rules (e.g.no-use-before-definefor the mutually recursiveisDayjs/Dayjs). - Zero-indexed months throughout, matching native
Date. $-prefixed internal fields ($d,$y,$L,$u,$x, …) to signal "private, do not depend on."
Complexity hotspots
| File | Note |
|---|---|
src/plugin/customParseFormat/index.js (~7.7 KB) |
The largest plugin: a token table, a hand-rolled parser, strict-mode validation, and format-array fallthrough. The densest logic in the codebase. |
src/plugin/duration/index.js (~8 KB) |
A second class (Duration) with its own parse/format/arithmetic surface and ISO 8601 duration handling. |
src/plugin/timezone/index.js (~5 KB) |
DST-aware offset resolution via Intl, including the fixOffset "hole/overlap" handling adapted from Luxon. |
src/index.js startOf/$set |
Branch-heavy unit switches; correct but worth reading carefully before editing. |
15. Internal Field Reference
These $-prefixed fields appear throughout the source. They are implementation detail — do not depend on them from application code — but you must understand them to read or modify the library.
| Field | Meaning |
|---|---|
$d |
The wrapped native Date |
$L |
Active locale name for this instance |
$y |
Cached full year |
$M |
Cached month (0–11) |
$D |
Cached date of month (1–31) |
$W |
Cached day of week (0–6) |
$H |
Cached hour |
$m |
Cached minute |
$s |
Cached second |
$ms |
Cached millisecond |
$u |
UTC-mode flag (set by utc plugin) |
$x |
Extension data bag (e.g. $x.$timezone, $x.$localOffset) |
$offset |
Fixed offset in minutes (utc/timezone plugins) |
$isDayjsObject |
Marker used by isDayjs for cross-realm detection |
$i |
(On a plugin function) "already installed" guard |
Shared Utils/$utils() keys: s padStart, z padZoneStr, m monthDiff, a absFloor, p prettyUnit, u isUndefined, l parseLocale, i isDayjs, w wrapper.
Factory statics: dayjs.Ls (locale registry), dayjs.en (English locale), dayjs.p (plugin flag registry), dayjs.extend, dayjs.locale, dayjs.isDayjs, dayjs.unix.
16. Potential Improvements and Tech Debt
These observations come directly from in-source markers and the project's tracked issues.
In-code markers
$offsetcoupling —wrapper()carries$offsetwith the commenttodo: refactor; do not use this.$offset in you code. The UTC/timezone offset handling is threaded through this shared field in a way the author flagged as needing cleanup.customParseFormatmeridiem matching — atodo: fix input === meridiem(i, 0, isLowerCase)marks an imprecise meridiem comparison loop that scans 1–24 hours.
Tracked issues (from the bundled issue snapshot)
| Issue | Status | Area |
|---|---|---|
| #1765 — Day.js does not actually support Node.js ESM | Open | Module/packaging |
#1437 — Timezone startOf DST off-by-one |
Open | timezone |
#3048 / #3049 / #3054 — weekOfYear week 53 for de-at / de-ch / nl-be |
Open | weekOfYear + locale |
#2970 — Disable rounding in duration().humanize() |
Open | duration |
#2888 — dayjs(undefined) vs dayjs() semantics |
Closed | Parsing |
Architectural suggestions
- ESM packaging (#1765) is the most consequential open item — proper
exports/conditional-exports support would resolve a long-standing interop pain point. - Locale-aware week numbering — the cluster of week-53 issues suggests
weekOfYear/weekYearcould use a more rigorous ISO/locale week algorithm, or clearer documentation of theyearStart/weekStartinteraction. - Configurable duration rounding (#2970) — exposing the rounding function on
humanize()(asrelativeTimealready does via itsroundingoption) would be a small, consistent addition. - DST edge cases in
timezone(#1437) remain the perennial hard problem; thefixOffsetlogic is sound but boundary handling around DST transitions warrants targeted tests.
Note
None of these are correctness problems in common use. They are edge cases (DST boundaries, specific regional week rules), ergonomics (ESM, rounding options), and one flagged internal refactor. The 100% coverage gate and multi-zone test matrix keep the mainline behavior dependable.
17. Contributing
Local setup
git clone https://github.com/iamkun/dayjs.git
cd dayjs
npm install
Development loop
# Lint (also runs on pre-commit)
npm run lint
# Full test matrix (multi-timezone + 100% coverage gate)
npm test
# Build and verify the size budget
npm run build
Important
Run the full
npm test, not a barejestinvocation — the multi-TZmatrix and the 100% line-coverage threshold are part of the contract. A change that drops coverage below 100% or breaks under a non-host timezone will fail CI.
Adding a plugin
- Create
src/plugin/<name>/index.jsexporting(option, Dayjs, dayjs) => { … }. - Patch
Dayjs.prototypeand/or adddayjs.*statics, always delegating to saved originals when wrapping. - Add
types/plugin/<name>.d.tsthatdeclare module 'dayjs'to augment theDayjsinterface or factory. - Add
test/plugin/<name>.test.jswith full coverage (including adeclarations.test.jstype check where relevant). - Document the new methods in
docs/.
Adding a locale
- Create
src/locale/<name>.jsexporting a locale object (name,weekdays,months,ordinal, and any ofweekStart,yearStart,formats,relativeTime,meridiem,calendar). - Self-register on import as the existing locales do.
- Add
test/locale/<name>.test.js.
Commit conventions
Commits drive semantic-release, so follow the conventional-commit format (feat:, fix:, docs:, chore:, …). The version, changelog, and GitHub release are generated from commit history — do not hand-edit the version in package.json (it stays 0.0.0-development).
Code style
- Conform to
eslint-config-airbnb-base; justify anyeslint-disablewith a comment. - Preserve immutability — route new instances through
wrapper(); never mutatethisin core methods. - Mind the size budget: prefer reusing
Utilshelpers over adding code to the core.
18. Quick Reference
File map
src/
constant.js # units, regexes, FORMAT_DEFAULT
utils.js # s,z,m,a,p,u helper object
index.js # dayjs() factory + Dayjs class + locale registry
locale/*.js # 143 locales (en built into core)
plugin/*/index.js # 37 plugins
types/
index.d.ts # core types + PluginFunc
plugin/*.d.ts # per-plugin augmentation
test/
*.test.js # core suites
plugin/*.test.js # per-plugin suites
locale/*.test.js # per-locale suites
issues/*.test.js # regression tests
Key npm scripts
| Script | Effect |
|---|---|
npm test |
Multi-TZ Jest + 100% coverage gate |
npm run build |
Bundle dayjs.min.js + size check |
npm run babel |
Build ESM under esm/ |
npm run lint |
ESLint over src, test, build |
npm run size |
Enforce 2.99 KB budget |
Plugin authoring contract
// src/plugin/<name>/index.js
export default (option, Dayjs, dayjs) => {
const proto = Dayjs.prototype
const old = proto.someMethod
proto.someMethod = function (...args) {
// new behavior, then delegate
return old.bind(this)(...args)
}
dayjs.someStatic = function () { /* … */ }
}
Hard plugin dependencies
| Plugin | Extend first |
|---|---|
timezone |
utc |
weekYear |
weekOfYear |
isoWeeksInYear |
isLeapYear |
duration (humanize) |
relativeTime |
Internal invariants
- New instances are produced only by
wrapper()(except inbadMutable). - All units normalize through
prettyUnitbefore branching. - The core imports no plugin and no non-English locale.
dayjs.min.jsmust stay ≤ 2.99 KB.