Back to Plotly Js

Type Generator Internals

src/types/GENERATOR.md

4.0.014.2 KB
Original Source

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 valTypes 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:

valTypeTS produced
data_arrayDatum[] | TypedArray
number, integernumber (with extras appended as literals; number | 'auto' style)
stringliteral union if values provided; matches a common-enum alias when applicable; otherwise string
booleanboolean
colorColor
colorscaleColorScale
colorlistColor[]
anglenumber | 'auto'
subplotidstring
enumeratedliteral union of values; matches a common-enum alias when applicable
flaglistunion of flags + extras + (string & {}) to allow +-joined combinations while preserving autocomplete
info_arraytuple of element valTypes when fixed-length; T[] when freeLength; any[] fallback
anyany

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.symbolMarkerSymbol).

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.