The Plugin Architecture: Monkey-Patching as a Feature
Prerequisites
- ›Article 1: Architecture and Code Map
- ›JavaScript prototype chain and this binding
- ›Understanding of .call() and method delegation
The Plugin Architecture: Monkey-Patching as a Feature
Most libraries treat monkey-patching as a code smell. Day.js makes it the foundation of its architecture. The entire plugin system—supporting 37 plugins that add UTC mode, timezone support, duration parsing, relative time, and more—is built on a single principle: save the old method, replace it with yours, call the old one when you're done. This article explores how that works, why it works well, and where it gets interesting.
The dayjs.extend() Mechanism
Plugin installation flows through a single function:
dayjs.extend = (plugin, option) => {
if (!plugin.$i) { // install plugin only once
plugin(option, Dayjs, dayjs)
plugin.$i = true
}
return dayjs
}
Three things to notice:
- Install-once guard: The
plugin.$iflag prevents double-installation. Since plugins modify the prototype, running them twice would create recursive chains where a method calls itself. - The triad: Plugins receive
(option, Dayjs, dayjs)—the user-provided config, the class constructor (for prototype access), and the factory function (for static methods). - Plugins are functions, not classes: No registration, no lifecycle hooks, no abstract methods. Just a function that receives the tools and does its work.
sequenceDiagram
participant User
participant extend as dayjs.extend()
participant Plugin as plugin(option, Dayjs, dayjs)
participant Proto as Dayjs.prototype
User->>extend: dayjs.extend(utcPlugin)
extend->>extend: Check plugin.$i
Note over extend: Not installed yet
extend->>Plugin: plugin(undefined, Dayjs, dayjs)
Plugin->>Proto: Save old parse(), replace with new
Plugin->>Proto: Save old init(), replace with new
Plugin->>Proto: Add .utc(), .local(), .isUTC()
Plugin-->>extend: Installation complete
extend->>extend: Set plugin.$i = true
extend-->>User: return dayjs (chainable)
The return value return dayjs makes extend() chainable: dayjs.extend(utc).extend(timezone).extend(advancedFormat).
Tip: The install-once guard uses a property on the function object itself (
plugin.$i). This is safe because JavaScript functions are objects, but it means you can't install a plugin with different options for different use cases. If you need that, you'd need a plugin factory that returns a fresh function each time.
The Prototype-Wrapping Pattern
This is the core pattern that makes the entire plugin ecosystem work. Every plugin that overrides existing behavior follows the same template:
const oldMethod = proto.method
proto.method = function (...args) {
// do something before
const result = oldMethod.call(this, ...args) // or .bind(this)
// do something after
return result
}
Let's see it in the UTC plugin's parse() override:
src/plugin/utc/index.js#L43-L52
const oldParse = proto.parse
proto.parse = function (cfg) {
if (cfg.utc) {
this.$u = true
}
if (!this.$utils().u(cfg.$offset)) {
this.$offset = cfg.$offset
}
oldParse.call(this, cfg)
}
The plugin saves proto.parse, replaces it, sets up UTC state ($u flag, $offset), then delegates to the original via oldParse.call(this, cfg). The original parse() in the core still runs—it just has UTC-aware setup before it.
When multiple plugins wrap the same method, this creates a chain:
sequenceDiagram
participant Call as .parse(cfg)
participant TZ as timezone plugin
participant CPF as customParseFormat plugin
participant UTC as utc plugin
participant Core as core parse()
Call->>TZ: timezone's parse()
TZ->>CPF: oldParse.call(this, cfg)
CPF->>UTC: oldParse.call(this, cfg)
UTC->>Core: oldParse.call(this, cfg)
Core->>Core: this.$d = parseDate(cfg)
Core->>Core: this.init()
The order depends on the order of extend() calls. Each plugin captures whatever proto.parse points to at installation time—which might be the core method or an already-wrapped version from a previous plugin.
The Format Pipeline: Three-Layer Composition
The format system is the most elegant demonstration of the wrapping pattern. Three plugins compose into a pipeline where each layer handles its own tokens before passing the result down:
flowchart LR
Input["format('LLLL')"] --> LF["localizedFormat<br/>LLLL → 'dddd, MMMM D, YYYY h:mm A'"]
LF --> AF["advancedFormat<br/>Q, Do, X, x, k, w → values"]
AF --> Core["core format()<br/>YYYY, MM, DD, etc. → values"]
Core --> Output["'Friday, January 25, 2019 12:00 AM'"]
Layer 1: localizedFormat expands locale-specific tokens. L becomes MM/DD/YYYY in English, YYYY/MM/DD in Chinese. The expansion uses a utility function that maps shorthand tokens to the locale's formats object:
src/plugin/localizedFormat/index.js#L4-L14
d.en.formats = englishFormats
proto.format = function (formatStr = FORMAT_DEFAULT) {
const { formats = {} } = this.$locale()
const result = u(formatStr, formats)
return oldFormat.call(this, result)
}
Layer 2: advancedFormat handles tokens like Q (quarter), Do (ordinal day), X/x (Unix timestamps), and k (24-hour time where midnight is 24):
src/plugin/advancedFormat/index.js#L6-L49
It replaces its tokens with literal values (or [escaped] strings), then passes the partially-resolved string to oldFormat.bind(this)(result).
Layer 3: core format() handles the standard tokens—YYYY, MM, DD, HH, mm, ss, SSS, Z, ZZ, and weekday/month names. This is the ~80-line matches function in index.js:
Plugin State: The $x Bag and dayjs.p Bus
As we saw in Part 1, every Dayjs instance has a $x object for plugin-specific state. The timezone plugin uses this to tag instances with their timezone:
ins.$x.$timezone = timezone // timezone plugin, line 113
For inter-plugin communication that doesn't belong to any instance, Day.js provides a shared bus object:
dayjs.p = {}
The customParseFormat plugin announces its presence via this bus:
src/plugin/customParseFormat/index.js#L218
d.p.customParseFormat = true
And the devHelper plugin checks for it to warn users who pass format strings without loading customParseFormat:
src/plugin/devHelper/index.js#L15-L17
if (cfg.args.length >= 2 && !d.p.customParseFormat) {
console.warn(`To parse a date-time string like ${date} using the given format...`)
}
flowchart TD
subgraph "Instance State ($x)"
A["$x.$timezone = 'America/New_York'"]
B["$x.$localOffset = -300"]
end
subgraph "Global Bus (dayjs.p)"
C["p.customParseFormat = true"]
end
subgraph Plugins
D[timezone] -->|writes| A
E[utc] -->|writes| B
F[customParseFormat] -->|writes| C
G[devHelper] -->|reads| C
end
Dissecting Real Plugins
badMutable: 8 Lines That Break the Rules
The badMutable plugin is the perfect case study for understanding Day.js immutability—because it deliberately breaks it:
src/plugin/badMutable/index.js#L1-L52
The key change is in $g:
proto.$g = function (input, get, set) {
if (this.$utils().u(input)) return this[get]
return this.$set(set, input) // $set instead of set
}
In the core, set() calls this.clone().$set(...), creating a new instance. badMutable bypasses the clone by calling $set() directly on this. The same pattern applies to startOf, add, and locale—each override extracts the Date from the result and writes it back to this.$d, then calls this.init() to refresh the cached properties.
This also explains why the comment on line 210 of index.js says // clone is for badMutable plugin—the $set method for month/year changes clones internally, specifically to avoid corrupting state when badMutable is active.
advancedFormat: Token Expansion via Wrapping
We covered the format pipeline above. What's notable about advancedFormat is that it uses this.week(), this.isoWeek(), this.weekYear(), and this.isoWeekYear()—methods that aren't on the core Dayjs class. They come from the weekOfYear, isoWeek, and weekYear plugins. If those plugins aren't loaded, calling advancedFormat with w or W tokens will throw.
This is an implicit dependency. Day.js doesn't enforce plugin ordering or declare dependencies—it trusts the user to load what they need.
devHelper: Runtime Warnings via the Plugin Bus
The devHelper plugin wraps parse(), locale(), and diff() to detect common mistakes:
src/plugin/devHelper/index.js#L2-L40
It checks process.env.NODE_ENV !== 'production' and strips itself in production builds. It warns when you pass a 13-digit string (probably a timestamp that should be a number), a 4-digit number (probably a year that should be a string), a format string without customParseFormat, or a locale that hasn't been loaded.
Plugin Categories and Dependency Map
The 37 plugins fall into natural categories:
| Category | Plugins |
|---|---|
| Parsing | customParseFormat, arraySupport, objectSupport, bigIntSupport |
| Display/Format | advancedFormat, localizedFormat, buddhistEra, calendar, duration |
| Query | isBetween, isSameOrBefore, isSameOrAfter, isLeapYear, isToday, isTomorrow, isYesterday, isMoment |
| Manipulation | utc, timezone, badMutable, pluralGetSet, negativeYear |
| Time Units | dayOfYear, weekOfYear, weekYear, isoWeek, isoWeeksInYear, quarterOfYear, weekday |
| Relative Time | relativeTime, toArray, toObject |
| i18n | localeData, updateLocale, preParsePostFormat |
| DX | devHelper, minMax |
Most plugins are independent, but several have implicit dependencies:
flowchart TD
timezone --> utc
advancedFormat -.->|optional| weekOfYear
advancedFormat -.->|optional| isoWeek
advancedFormat -.->|optional| weekYear
duration -.->|for humanize| relativeTime
localizedFormat -.->|shares utils| customParseFormat
buddhistEra --> advancedFormat
isoWeeksInYear --> isoWeek
devHelper -.->|detects| customParseFormat
Solid arrows indicate hard dependencies (the plugin will error without it). Dashed arrows indicate soft dependencies (features degrade gracefully or are optional).
Tip: When adding plugins, load them in dependency order. A common setup is:
dayjs.extend(utc).extend(timezone).extend(advancedFormat).extend(localizedFormat).extend(relativeTime). Loadingtimezonebeforeutcwon't cause an error—timezonewraps methods thatutchasn't installed yet—but the behavior will be wrong.
What's Next
We've seen how dayjs.extend() turns monkey-patching into a first-class extension mechanism—one that keeps the core under 3KB while supporting rich functionality through composition. The prototype-wrapping pattern creates middleware chains, the $x bag provides per-instance state, and the dayjs.p bus enables inter-plugin communication.
In the next article, we'll go deep on the most complex application of this plugin system: date parsing and the three-layer UTC/timezone stack. We'll trace how a string like "2024-03-10 02:30" gets parsed in America/New_York during a DST transition, and how the fixOffset() algorithm (borrowed from Luxon) resolves the ambiguity.