Read OSS

The Plugin Architecture: Monkey-Patching as a Feature

Intermediate

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:

src/index.js#L448-L454

dayjs.extend = (plugin, option) => {
  if (!plugin.$i) { // install plugin only once
    plugin(option, Dayjs, dayjs)
    plugin.$i = true
  }
  return dayjs
}

Three things to notice:

  1. Install-once guard: The plugin.$i flag prevents double-installation. Since plugins modify the prototype, running them twice would create recursive chains where a method calls itself.
  2. The triad: Plugins receive (option, Dayjs, dayjs)—the user-provided config, the class constructor (for prototype access), and the factory function (for static methods).
  3. 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:

src/index.js#L262-L342

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:

src/index.js#L466

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). Loading timezone before utc won't cause an error—timezone wraps methods that utc hasn'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.