Read OSS

The Option Resolution Engine: Defaults, Scopes, Proxies, and Scriptable Options

Advanced

Prerequisites

  • Articles 1-2
  • Strong understanding of JavaScript Proxies (get/ownKeys traps)
  • Familiarity with Object.defineProperties and property descriptors

The Option Resolution Engine: Defaults, Scopes, Proxies, and Scriptable Options

If there's one subsystem in Chart.js that earns the label "overengineered in the best way," it's the option resolution engine. When you set backgroundColor: 'red' on a dataset, that value must be resolved against dataset-level options, chart-type overrides, element defaults, and global defaults—in the correct order, with support for functions that receive runtime context, arrays indexed by data point, and live fallback chains that reflect runtime changes. This article explains how all of that works.

The Three Global Stores: defaults, overrides, descriptors

Chart.js maintains three separate singleton stores, all created in core.defaults.js:

src/core/core.defaults.js#L7-L8

classDiagram
    class defaults {
        +backgroundColor: string
        +borderColor: string
        +color: string
        +font: object
        +elements: object
        +datasets: object
        +plugins: object
        +scales: object
        +set(scope, values)
        +get(scope)
        +route(scope, name, target, targetName)
        +describe(scope, values)
        +override(scope, values)
    }

    class overrides {
        <<prototype: null>>
        +bar: object
        +line: object
        +doughnut: object
        Note: chart-type-specific settings
    }

    class descriptors {
        <<prototype: null>>
        +_scriptable: fn
        +_indexable: fn
        Note: metadata about option properties
    }

    defaults --> overrides : override() writes here
    defaults --> descriptors : describe() writes here

defaults is an instance of the Defaults class, initialized with base values like color: '#666' and font.size: 12. It serves as the ultimate fallback for any option.

overrides stores chart-type-specific settings. When you register a BarController, its overrides (like { scales: { x: { type: 'category' } } }) are merged into this store. These take precedence over defaults but yield to user-supplied options.

descriptors stores metadata about option properties—specifically, which properties are scriptable (can be functions) and which are indexable (can be arrays). The initial descriptors declare that all properties starting with on (like onHover) are not scriptable, and that events is not indexable.

The Defaults Class and defaults.route()

The Defaults class is deceptively simple on the surface but contains one of the most clever patterns in the codebase—the route() method:

src/core/core.defaults.js#L130-L157

flowchart LR
    subgraph "route('elements.arc', 'backgroundColor', '', 'color')"
        ARC_BG["elements.arc.backgroundColor"] -->|"getter: valueOrDefault(this._backgroundColor, defaults.color)"| DEFAULTS_COLOR["defaults.color"]
    end

    DEV["defaults.color = 'blue'"] -->|runtime change| DEFAULTS_COLOR
    ARC_BG -->|"Now resolves to 'blue'"| RESULT["'blue'"]

When route() is called, it uses Object.defineProperties to replace a simple property with a getter/setter pair. The getter checks a private _backgroundColor property first; if it's undefined, it falls back to the target scope (defaults.color). The setter writes to the private property.

This creates live fallback chains: if you change defaults.color at runtime, every element that routes its backgroundColor to defaults.color will immediately reflect the change. This wouldn't work with simple property copying.

The route() mechanism is invoked during component registration. When ArcElement is registered, its defaultRoutes property specifies that backgroundColor should fall back to the global color. This is how Chart.js achieves cascading defaults without duplicating values.

The Config Class and Scope Resolution

The Config class manages the computation of ordered scope arrays—the key data structure that resolvers walk to find option values:

src/core/core.config.js#L158-L381

The chartOptionScopes() method returns the scopes for resolving chart-level options:

src/core/core.config.js#L331-L342

sequenceDiagram
    participant Consumer as chart.options.X
    participant Resolver as Proxy Resolver
    participant S1 as options (user config)
    participant S2 as overrides[type]
    participant S3 as defaults.datasets[type]
    participant S4 as defaults
    participant S5 as descriptors

    Consumer->>Resolver: get 'backgroundColor'
    Resolver->>S1: has 'backgroundColor'?
    S1-->>Resolver: undefined
    Resolver->>S2: has 'backgroundColor'?
    S2-->>Resolver: undefined
    Resolver->>S3: has 'backgroundColor'?
    S3-->>Resolver: undefined
    Resolver->>S4: has 'backgroundColor'?
    S4-->>Resolver: 'rgba(0,0,0,0.1)'
    Resolver-->>Consumer: 'rgba(0,0,0,0.1)'

For dataset element options, the scope chain is even longer. The datasetElementScopeKeys() method at line 252 produces keys like ['datasets.bar.elements.point', 'datasets.bar', 'elements.point', ''], and getOptionScopes() at line 296 walks each key against mainScope, options, overrides[type], defaults, and descriptors:

src/core/core.config.js#L296-L325

The result is a Set of scope objects, converted to an array. Scope caching (_scopeCache and _resolverCache) ensures this computation happens at most once per unique key list.

Proxy-Based Resolvers: _createResolver()

The actual resolution magic lives in helpers.config.ts. The _createResolver() function creates a JavaScript Proxy that lazily resolves options by walking scope arrays:

src/helpers/helpers.config.ts#L27-L108

The get trap at line 64 is where resolution happens. It calls _resolveWithPrefixes(), which tries each prefix in order. For example, if the prefixes are ['hover', ''] and you access borderColor, it first looks for hoverBorderColor across all scopes, then falls back to borderColor:

src/helpers/helpers.config.ts#L390-L405

flowchart TD
    ACCESS["proxy.borderColor"] --> CACHED{"Already cached?"}
    CACHED -->|Yes| RETURN["Return cached value"]
    CACHED -->|No| PREFIXES["Try prefixes: ['hover', '']"]
    PREFIXES --> P1["Look for 'hoverBorderColor' in scopes"]
    P1 -->|Found| CHECK_SUB{"Is value an object<br/>needing sub-resolver?"}
    P1 -->|Not found| P2["Look for 'borderColor' in scopes"]
    P2 -->|Found| CHECK_SUB
    P2 -->|Not found| UNDEF["Return undefined"]
    CHECK_SUB -->|Yes| SUB["createSubResolver()"]
    CHECK_SUB -->|No| CACHE["Cache & return value"]
    SUB --> CACHE

Resolved values are cached directly on the Proxy target object. The _cached() helper checks Object.prototype.hasOwnProperty before invoking the resolver, ensuring each property is resolved at most once.

Tip: When debugging option resolution, you can inspect the internal scopes of a resolver by accessing resolver._scopes in the debugger. This reveals the ordered array of scope objects being searched.

When the resolved value is a plain object (prototype is null or Object), createSubResolver() creates a nested resolver with its own scope chain. This is how options like font.size and ticks.color resolve through the same mechanism—the resolver for font is itself a Proxy that walks scopes looking for font sub-properties.

Context Attachment and Scriptable Options

The second Proxy layer, _attachContext(), wraps a resolver with runtime context to support scriptable and indexable options:

src/helpers/helpers.config.ts#L118-L195

When the context-aware get trap encounters a function value for a scriptable property, it calls _resolveScriptable():

src/helpers/helpers.config.ts#L255-L273

The scriptable resolver calls the function with (context, subProxy), where context contains { chart, dataIndex, datasetIndex, ... }. The _stack Set at line 262 detects infinite recursion—if a scriptable option references another option that circularly references back, the stack trace is captured and thrown as an error rather than causing a browser hang.

For indexable options (arrays), _resolveArray() at line 275 checks if the context has an index property and returns value[context.index % value.length]. This is how backgroundColor: ['red', 'blue', 'green'] assigns different colors to different data points.

Performance Optimization: needContext()

Not every option resolution needs the overhead of context Proxies. The needContext() function performs a fast scan to determine whether any of the named properties resolve to functions or arrays:

src/core/core.config.js#L405-L418

flowchart TD
    START["resolveNamedOptions(scopes, names, context)"] --> RESOLVE["Create base resolver"]
    RESOLVE --> CHECK["needContext(resolver, names)"]
    CHECK -->|"All static values"| SHARED["result.$shared = true<br/>Read values directly from resolver"]
    CHECK -->|"Has functions or arrays"| CONTEXT["result.$shared = false<br/>Create context-wrapped resolver"]
    CONTEXT --> READ["Read values from context resolver"]
    SHARED --> READ
    READ --> RETURN["Return result object"]

If needContext() returns false, the resolved options are marked $shared: true. This flag is used by DatasetController in a powerful optimization: when all elements in a dataset share identical static options, they can reference a single shared options object instead of creating per-element copies. We'll explore this optimization in detail in Article 5.

The hasFunction() helper at line 402 even checks for objects with function-valued properties, ensuring that a static object like { size: 12 } doesn't trigger context wrapping, while { size: (ctx) => ctx.active ? 14 : 12 } does.

Bridging to the Next Article

The option resolution engine is consumed by virtually every other subsystem—controllers resolve element options through it, scales resolve tick options, and plugins access their configuration. But how do controllers, scales, and plugins get registered in the first place? In the next article, we'll explore the Registry and Plugin system—how component registration triggers defaults merging and route setup, and how the plugin lifecycle orchestrates a rich set of hooks throughout a chart's existence.