Jesse A. Green / notebook

Day.js Admin & Architecture Guide

Generated June 2026 from iamkun/dayjs at 6609c5e

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

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

  1. Executive Summary
  2. Architecture Overview
  3. Component Inventory
  4. The Core Engine
  5. The Plugin System
  6. The Parsing Pipeline
  7. The Formatting Pipeline
  8. Immutability and the Wrapper Model
  9. The Locale System
  10. Requirements
  11. Build and Release Pipeline
  12. Testing Strategy
  13. Security Analysis
  14. Code Quality and Patterns
  15. Internal Field Reference
  16. Potential Improvements and Tech Debt
  17. Contributing
  18. 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 a size-limit budget.
  • Zero runtime dependencies. Every entry in package.json is a devDependency. 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-named badMutable plugin.
  • 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 to src/.


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) through MILLISECONDS_A_WEEK, derived from SECONDS_A_*. Used by add, diff, and the duration plugin.
  • 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 when format() is called with no argument.
  • INVALID_DATE_STRING = 'Invalid Date' — the sentinel isValid() 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) during format().

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 — u is "is undefined," p is "pretty unit," s is "pad start."

At load time, index.js augments this object with three more functions that need closure over core internals:

  • Utils.l = parseLocale — locale resolution
  • Utils.i = isDayjs — type predicate
  • Utils.w = wrapper — the immutable instance factory

src/index.js — the engine

This file defines:

  • dayjs(date, c) — the factory. If date is already a Day.js object it returns a clone; otherwise it builds a config object ({ date, args }, merging any options in c) and constructs a Dayjs.
  • class Dayjs — the instance type. The constructor resolves the locale, parses the date, seeds the $x extension bag, and tags the instance with $isDayjsObject.
  • wrapper(date, instance) — creates a new dayjs carrying forward locale, 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') and Ls (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 $g helper, so year(), month(), etc. are generated rather than hand-written.
  • Static surface — dayjs.extend, dayjs.locale, dayjs.isDayjs, dayjs.unix, dayjs.en, dayjs.Ls, and dayjs.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:

  1. option — the optional second argument passed to dayjs.extend(plugin, option).
  2. c — the Dayjs class (so the plugin can patch c.prototype).
  3. d — the dayjs factory (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.$i flag guards against double installation — calling extend with the same plugin twice is a no-op. Because the flag lives on the plugin function object, it is process-global. This makes extend idempotent 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: timezone requires utc; weekYear requires weekOfYear; isoWeeksInYear requires isLeapYear; duration.humanize() requires relativeTime. Establish a single, ordered extend block 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:

  1. null → new Date(NaN) (explicitly invalid).
  2. undefined → new Date() (now).
  3. Date instance → new Date(date) (defensive copy).
  4. String without a trailing Z → matched against REGEX_PARSE. On a match, components are extracted (month is decremented because Date months are zero-indexed; milliseconds are truncated to three digits) and a local or UTC Date is constructed.
  5. 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 the customParseFormat plugin closes by replacing parse entirely 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 → Date via Date.UTC.apply or the Date constructor.
  • 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 $u flag and switches init() to getUTC* 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:

  1. If the instance is invalid, return the locale's invalidDate or 'Invalid Date'.
  2. Default the template to FORMAT_DEFAULT (YYYY-MM-DDTHH:mm:ssZ).
  3. Compute the zone string with Utils.z.
  4. 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 ZZ case).

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:

  • localizedFormat expands L/LL/LLLL (and lowercase variants) using the locale's formats map and the English fallbacks in localizedFormat/utils.js.
  • advancedFormat handles Q, Do, X, x, w/W, k, z, etc., then calls the old format.
  • buddhistEra substitutes BBBB/BB.
  • preParsePostFormat runs the locale's postformat on the result (for non-Latin numerals).
  • utc changes the default template to YYYY-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-inner format.


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 return wrapper(...) results.
  • $set (the private setter) mutates a clone, never this directly in the public path — set() calls this.clone().$set(...).

Important

The badMutable plugin breaks this contract intentionally. It redefines set, startOf, add, locale, etc. to assign back to this.$d and call this.init(), returning this. 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 object if provided, and falls back to the language part of a lang-region code if the full code isn't loaded (e.g. en-xyz → en).
  • With an object preset, registers it under its name.
  • Unless isLocal is set, it updates the global L.

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 call dayjs.locale(name, localeObject, true) on load, registering themselves into Ls without changing the global default. That self-registration on import is why import '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

updateLocale mutates the shared locale object in Ls in 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.js is 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-commit hook runs lint before 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 — note package.json's version is 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 TZ environment 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. Run npm test (not just jest) 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_PARSE is 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() or customParseFormat template 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-mode customParseFormat + 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 devHelper out of production builds (it is auto-suppressed when NODE_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 — monthDiff is copied from Moment.js specifically to reproduce its results; comparisons in tests assert against moment.

Conventions

  • ESLint airbnb-base with targeted eslint-disable comments where the size/parity goals conflict with default rules (e.g. no-use-before-define for the mutually recursive isDayjs/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

  • $offset coupling — wrapper() carries $offset with the comment todo: 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.
  • customParseFormat meridiem matching — a todo: 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/weekYear could use a more rigorous ISO/locale week algorithm, or clearer documentation of the yearStart/weekStart interaction.
  • Configurable duration rounding (#2970) — exposing the rounding function on humanize() (as relativeTime already does via its rounding option) would be a small, consistent addition.
  • DST edge cases in timezone (#1437) remain the perennial hard problem; the fixOffset logic 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 bare jest invocation — the multi-TZ matrix 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

  1. Create src/plugin/<name>/index.js exporting (option, Dayjs, dayjs) => { … }.
  2. Patch Dayjs.prototype and/or add dayjs.* statics, always delegating to saved originals when wrapping.
  3. Add types/plugin/<name>.d.ts that declare module 'dayjs' to augment the Dayjs interface or factory.
  4. Add test/plugin/<name>.test.js with full coverage (including a declarations.test.js type check where relevant).
  5. Document the new methods in docs/.

Adding a locale

  1. Create src/locale/<name>.js exporting a locale object (name, weekdays, months, ordinal, and any of weekStart, yearStart, formats, relativeTime, meridiem, calendar).
  2. Self-register on import as the existing locales do.
  3. 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 any eslint-disable with a comment.
  • Preserve immutability — route new instances through wrapper(); never mutate this in core methods.
  • Mind the size budget: prefer reusing Utils helpers 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 in badMutable).
  • All units normalize through prettyUnit before branching.
  • The core imports no plugin and no non-English locale.
  • dayjs.min.js must stay ≤ 2.99 KB.