The Option Resolution Engine: Defaults, Scopes, Proxies, and Scriptable Options
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._scopesin 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.