plotly.js
Version:
The open source javascript graphing library that powers plotly
368 lines (292 loc) • 16.1 kB
Markdown
# Type Generator Internals
The **schema-based generator** (`tasks/generate_schema_types.mjs`)
reads `plot-schema.json` and emits all the schema-derived TypeScript
types into `src/types/generated/schema.d.ts`:
- Common enum aliases (Calendar, Dash, AxisType, PatternShape, XRef, YRef,
TransitionEasing, TraceType — plus a deprecated `PlotType` alias)
- Shared sub-interfaces (Font, ColorBar, HoverLabel, etc.)
- Data interfaces for each trace type (BarData, ScatterData, IndicatorData, etc.)
and the `Data` discriminated union over all of them
- Layout component interfaces (LayoutAxis, Legend, Scene, Annotation, etc.)
and the Layout interface itself
- Animation / frame / edits interfaces (AnimationOpts, Frame, Edits)
- An `_internal` namespace wrapping types whose direct names would mislead
consumers or are schema-internal helpers
Run via `npm run schema`.
## How it works
`tasks/generate_schema_types.mjs` is called by `tasks/schema.mjs` after
writing `plot-schema.json`. The generator walks `schema.traces`,
`schema.layout.layoutAttributes`, `schema.animation`, `schema.frames`, and
`schema.config` (including `schema.config.edits`), mapping each attribute's
`valType` metadata to a TypeScript type. The set of meta keys to strip
during emission is read from `schema.defs.metaKeys` so any addition to the
schema's metadata format is picked up automatically.
### Phase 0: Common enum discovery
`discoverCommonTypes(schema)` walks the schema looking for enumerated
attributes whose key/path/values match an entry in `COMMON_TYPE_ANCHORS`:
```js
const COMMON_TYPE_ANCHORS = [
{ name: 'Calendar', match: (key) => /^[xyz]?calendar$/.test(key) },
{ name: 'Dash', match: (key) => key === 'dash' },
{ name: 'AxisType', match: (key, path) => key === 'type' && /[xyz]axis\.type$/.test(path) },
// ...
];
```
When multiple sites match an anchor (e.g. 3D scene axes vs cartesian axes
both have `xaxis.type` enumerations), the generator picks the largest
value set — the superset — so the alias is always permissive enough.
`TraceType` is special-cased: derived from `Object.keys(schema.traces)`
rather than from an attribute. A deprecated `PlotType = TraceType` alias
is also emitted for back-compat with prior versions of the type surface.
Each discovered alias is emitted as `export type Name = 'a' | 'b' | ...`
and registered in `VALUES_TO_COMMON_TYPE` so subsequent emission of any
attribute whose `values` array matches an anchor produces a reference
to the alias instead of an inlined literal union.
### Phase 1: Fingerprinting
The generator fingerprints every container subtree across all traces and
layout. Two containers are considered identical when their sorted keys
and leaf `valType`s produce the same fingerprint string.
### Phase 2: Shared interface extraction
Containers that appear at least `MIN_OCCURRENCES` times AND have at
least `MIN_PROPERTIES` properties become shared interfaces (Font,
ColorBar, HoverLabel, etc.). PascalCase naming is controlled by
`SHARED_NAME_OVERRIDES` so e.g. `colorbar` becomes `ColorBar` rather than
`Colorbar`:
```js
const SHARED_NAME_OVERRIDES = new Map([
['colorbar', 'ColorBar'],
['hoverlabel', 'HoverLabel'],
['tickformatstops', 'TickFormatStops'],
['autorangeoptions', 'AutoRangeOptions'],
['legendgrouptitle', 'LegendGroupTitle'],
['error_y', 'ErrorY'],
['error_x', 'ErrorX'],
]);
```
Names in `SHARED_NAME_OVERRIDES` bypass `MIN_PROPERTIES`, so small
containers like `ErrorX` / `ErrorY` (3 properties) can be opted in as shared.
After fingerprinting completes, the generator **injects** the `transition`
and `frame` subtrees from `schema.animation` as shared types (`Transition`
and `AnimationFrameOpts`). These occur fewer than `MIN_OCCURRENCES` times
so the automatic extractor skips them, but they need to be named for
`AnimationOpts` to reference them cleanly.
### Phase 3: Trace interfaces
Each trace gets an interface (`ScatterData`, `BarData`, etc.) whose
properties reference shared types where fingerprints match. After all
trace interfaces are emitted, the generator also emits the discriminated
union `Data = Partial<BarData> | Partial<BarpolarData> | …` covering every
trace — narrowed via the `type` field. Adding or removing a trace in the
schema flows through automatically.
### Phase 4: Layout types
Before generation, `mergeTraceLayoutAttributes` folds every
`schema.traces[<type>].layoutAttributes` map into the layout attribute tree.
Trace modules contribute layout keys such as `barmode`, `boxmode` and
`piecolorway`, and the schema files them under the trace that contributes
them. At runtime they are ordinary layout keys, so the generated types must
carry them.
Most modules contribute to the top level of layout. barpolar is the
exception: its `supplyLayoutDefaults` coerces from `layoutIn[trace.subplot]`,
so `layout.polar.barmode` is the real key and `layout.barmode` does nothing
for a barpolar trace. `SUBPLOT_SCOPED_TRACE_LAYOUT` maps barpolar to `polar`,
which is why `Layout.barmode` allows all four bar values while
`PolarLayout.barmode` allows only `'stack'` and `'overlay'`. Add an entry
there if another module ever reads its layout attributes from a subplot.
Two modules that contribute the same key to the same target must agree on
the definition. If they disagree, generation throws rather than guessing,
because one property cannot describe both. The repo shares such keys by
reference instead of copying them, so a disagreement means either the shared
require was broken, or the two modules mean genuinely different things and
one of them belongs in its own container.
Layout generation then handles three categories:
- **Subplot containers** (`_isSubplotObj` flag) — grouped by target name
and merged into supersets. E.g., `xaxis` and `yaxis` both map to
`LayoutAxis` with the union of all their keys.
- **Linked-to-array containers** (detected via `{items: {name: {...}}}`)
— extracted as named interfaces (Annotation, Shape, Slider, etc.).
In the Layout interface they appear as arrays: `annotations?: Annotation[]`.
- **Regular containers** — inlined or referenced as shared types.
The Layout interface includes subplot index signatures:
```ts
[key: `xaxis${number}`]: LayoutAxis;
[key: `yaxis${number}`]: LayoutAxis;
// etc.
```
### Phase 5: Animation, frame, edits, and config interfaces
`AnimationOpts` is emitted from `schema.animation` (references the
injected `Transition` and `AnimationFrameOpts` shared types). `Frame` is
emitted from `schema.frames.items.frames_entry` with **field overrides**
for the recursively-typed fields the schema describes as `valType: any`:
```js
attrsToProperties(frameEntry, ' ', 'frame', sharedTypes, {
data: 'any[]',
layout: 'Partial<Layout>'
});
```
The override mechanism is the `fieldOverrides` param on `attrsToProperties`
— useful for any field whose schema description is too loose because the
schema can't self-reference. `Edits` is emitted from `schema.config.edits`
without overrides (all fields are concrete booleans).
`ConfigBase` is emitted from `schema.config` after registering Edits'
fingerprint in `sharedTypes`, so `edits?: Edits` references the named
interface rather than re-inlining the subtree. Six config fields whose
schema `valType` is `any` (`locales`, `modeBarButtons`,
`modeBarButtonsToAdd`, `modeBarButtonsToRemove`, `setBackground`,
`toImageButtonOptions`) come through as `any`; the hand-written `Config`
in `core/config.d.ts` overrides them via
`Omit<ConfigBase, keyof ConfigOverrides> & ConfigOverrides`.
### Phase 6: Internal namespace
Names in `INTERNAL_INTERFACES` are wrapped in `export namespace _internal {
... }` rather than emitted at the top level:
```js
const INTERNAL_INTERFACES = new Set([
'AutoRangeOptions', 'ErrorY', 'Lighting', 'Line', 'Marker'
]);
```
When emitting a reference to one of these types from outside the namespace
(e.g. `ScatterData.marker?: _internal.Marker`), the generator calls
`refName(name, /*inInternalNamespace=*/false)` which adds the `_internal.`
prefix. Inside the namespace, the same helper returns the bare name so
sibling references stay clean (`Marker.line?: Line`).
This pattern makes the names module-private to consumers: `import {
Marker }` from `plotly.js` fails because `Marker` isn't a top-level
declaration. The names are only reachable via `_internal.Marker` (or via
indexed access on the parent type — `ScatterData['marker']`, which is the
preferred path).
Why a namespace and not just dropping `export`? TypeScript's `.d.ts` file
semantics let non-exported top-level declarations leak through `export *`
re-exports — a long-standing quirk for backwards compatibility with
hand-written DefinitelyTyped declarations. Wrapping the names in a
namespace makes them non-top-level, so the leak doesn't apply.
### JSDoc descriptions and metadata
`formatJSDoc(attr, indent)` emits a multi-line JSDoc block for each leaf
attribute. The block contains the schema's `description` plus `@default`,
numeric bounds (`min`/`max`), and any `impliedEdits` lines when present.
Containers (no `valType`) only carry a description, so the metadata
branches no-op. Plotly's `*emphasis*` markers are preserved as-is (they
render as italics in IDE hover tooltips). Any `*/` sequences in
descriptions are escaped to prevent prematurely closing the comment.
### Output structure
```
src/types/generated/schema.d.ts
├── import { Color, ColorScale, Datum, MarkerSymbol, TypedArray } from '../lib/common'
├── Common enum types (Calendar, Dash, AxisType, PatternShape, XRef, YRef,
│ TransitionEasing, TraceType + deprecated PlotType alias)
├── Shared interfaces — public (Font, FontArray, ColorBar, HoverLabel, Domain,
│ Pattern, TickFormatStops, LegendGroupTitle, ...)
├── Internal shared interfaces in `namespace _internal` (Marker, Line,
│ AutoRangeOptions,
│ Lighting, ErrorY)
├── Trace interfaces (ScatterData, BarData, ... — 46 traces)
├── Data union (`Partial<*Data>` over every trace, discriminated by `type`)
├── Layout component interfaces (LayoutAxis, Legend, Scene, Annotation, etc.)
├── Layout interface
└── Animation / frames / config (AnimationOpts, Frame, Edits, ConfigBase)
```
## valType → TypeScript mapping
Summary:
| valType | TS produced |
|---|---|
| `data_array` | `Datum[] \| TypedArray` |
| `number`, `integer` | `number` (with `extras` appended as literals; `number \| 'auto'` style) |
| `string` | literal union if `values` provided; matches a common-enum alias when applicable; otherwise `string` |
| `boolean` | `boolean` |
| `color` | `Color` |
| `colorscale` | `ColorScale` |
| `colorlist` | `Color[]` |
| `angle` | `number \| 'auto'` |
| `subplotid` | `string` |
| `enumerated` | literal union of `values`; matches a common-enum alias when applicable |
| `flaglist` | union of flags + extras + `(string & {})` to allow `+`-joined combinations while preserving autocomplete |
| `info_array` | tuple of element `valType`s when fixed-length; `T[]` when `freeLength`; `any[]` fallback |
| `any` | `any` |
`arrayOk: true` wraps the result in `T | T[]`.
Attribute name overrides via `ATTR_NAME_OVERRIDES` map specific attribute
paths to a type alias regardless of valType (e.g. `marker.symbol` →
`MarkerSymbol`).
Reserved keys stripped from the output come from `schema.defs.metaKeys` —
currently `editType`, `role`, `description`, `impliedEdits`,
`_isSubplotObj`, `_isLinkedToArray`, `_arrayAttrRegexps`, `_deprecated`.
New additions to that list are picked up automatically on regen.
## Extending the schema generator
### Adding a new common enum alias (Calendar/Dash style)
If the schema repeats the same enumerated value-set in multiple places and
you want a named alias for it:
1. Add an entry to `COMMON_TYPE_ANCHORS` with a `name` and a
`match(key, path, values)` predicate that uniquely identifies the
anchor attribute. The discovery walker picks the largest matching
value set as the canonical alias body.
2. Run `npm run schema`. The generator emits `export type <Name> = ...`
and rewrites matching enumerated attributes to reference the alias.
3. Run `npm run typecheck` to verify.
`TraceType` is a special case derived from `Object.keys(schema.traces)`
rather than from an enumerated attribute; it doesn't follow the anchor
mechanism. A deprecated `PlotType = TraceType` alias is emitted alongside it
to keep older imports working.
### Adding a new layout container
If a new subplot type or array container is added to the schema:
1. Add an entry to `LAYOUT_CONTAINER_NAMES` (for subplots marked
`_isSubplotObj`) or `LAYOUT_ARRAY_NAMES` (for linked-to-array
containers).
2. Run `npm run schema` to regenerate.
3. Run `npm run typecheck` to ensure no regressions.
### Hiding a type inside `_internal`
Add the name to `INTERNAL_INTERFACES`. The generator will wrap it in the
`_internal` namespace and rewrite all external references to the
`_internal.X` form. Use this when:
- The name would mislead consumers (it's only one trace's variant, or the
semantics don't match the bare name).
- The type is a schema-internal helper that consumers rarely construct
directly.
- A hand-written type supersedes it (e.g. `ErrorY` is hidden because
`ErrorBar` is the preferred public type).
### Adding a field override
If a schema attribute is `valType: 'any'` because it's recursively
self-referential (e.g. `Frame.data` is "the same shape as the trace
data"), pass a `fieldOverrides` map to `attrsToProperties`:
```js
attrsToProperties(frameEntry, ' ', 'frame', sharedTypes, {
data: 'any[]',
layout: 'Partial<Layout>'
});
```
The override bypasses the schema-derived type for those field names. Use
this sparingly — when the schema CAN be improved at the source (in the
JS attribute file), prefer that.
### Improving flaglist support
Flag lists like `hoverinfo` currently emit a union with `(string & {})`
to allow flag combinations while preserving autocomplete for individual
flags. A fully combinatorial union (`'x' | 'x+y' | 'x+y+text' | ...`)
would produce huge types — 15+ members for `hoverinfo` — and slow down
type checking. Not implemented today; consider whether the autocomplete
win is worth the cost before changing this.
## Debugging
If the schema generator emits unexpected types:
```bash
npm run schema # regenerate and inspect schema.d.ts
npm run typecheck # see what tsc thinks
```
Inspect the schema directly:
```js
const s = require("test/plot-schema.json");
console.log(s.layout.layoutAttributes.xaxis); // inspect layout attrs
console.log(s.traces.scatter.attributes); // inspect trace attrs
```
## Public API re-export
`lib/index.d.ts` uses `export type * from '../src/types/generated/schema'`,
so every top-level exported type from `schema.d.ts` is automatically
re-exported to consumers. Types inside the `_internal` namespace are still
reachable via `_internal.X` (the namespace itself is exported by the
wildcard) but their bare names are not.
## CI integration
`npm run schema-typegen-diff-check` runs the generator and then verifies that
both `test/plot-schema.json` and `src/types/generated/schema.d.ts` are unchanged
via `git diff --exit-code`. If either differs, the command fails with exit code 1
and outputs the diff to the console.
The path names `schema.d.ts` rather than the whole `generated/` directory, so an
uncommitted change to the entry point declarations cannot fail this check.
`npm run entry-point-types-check` covers those.
This is what makes the JS-to-TS conversion workflow safe: a correct
conversion produces a byte-identical schema, so the check passes; an
incorrect conversion (typo in a `values` array, missed default, wrong
`valType`) changes the schema and CI fails until the developer fixes the
source or commits a deliberate change.