plotly.js
Version:
The open source javascript graphing library that powers plotly
342 lines (270 loc) • 14.5 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
Layout generation 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/` are unchanged via
`git diff --exit-code`. If either differs, the command fails with exit code 1
and outputs the diff to the console.
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.