src/types/GENERATOR.md
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:
PlotType alias)Data discriminated union over all of them_internal namespace wrapping types whose direct names would mislead
consumers or are schema-internal helpersRun via npm run schema.
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.
discoverCommonTypes(schema) walks the schema looking for enumerated
attributes whose key/path/values match an entry in COMMON_TYPE_ANCHORS:
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.
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.
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:
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.
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.
Layout generation handles three categories:
_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.{items: {name: {...}}})
— extracted as named interfaces (Annotation, Shape, Slider, etc.).
In the Layout interface they appear as arrays: annotations?: Annotation[].The Layout interface includes subplot index signatures:
[key: `xaxis${number}`]: LayoutAxis;
[key: `yaxis${number}`]: LayoutAxis;
// etc.
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:
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.
Names in INTERNAL_INTERFACES are wrapped in export namespace _internal { ... } rather than emitted at the top level:
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.
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.
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)
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 valTypes 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.
If the schema repeats the same enumerated value-set in multiple places and you want a named alias for it:
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.npm run schema. The generator emits export type <Name> = ...
and rewrites matching enumerated attributes to reference the alias.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.
If a new subplot type or array container is added to the schema:
LAYOUT_CONTAINER_NAMES (for subplots marked
_isSubplotObj) or LAYOUT_ARRAY_NAMES (for linked-to-array
containers).npm run schema to regenerate.npm run typecheck to ensure no regressions._internalAdd 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:
ErrorY is hidden because
ErrorBar is the preferred public type).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:
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.
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.
If the schema generator emits unexpected types:
npm run schema # regenerate and inspect schema.d.ts
npm run typecheck # see what tsc thinks
Inspect the schema directly:
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
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.
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.