A plugin's type argument names its own keys
Amends ADR 0019. That record fixed where a plugin
installs. It never said what a plugin's own TProps argument means once installed, and issue #530
found that meaning ambiguous in a way that broke a typed install.
Context
Dataset<TProps> and Gantt<TProps> both took plugins?: readonly PluginOf<unknown, Dataset<TProps>>[]
and plugins?: readonly ChromePlugin<TProps>[], tying a plugin's own generic to the consumer's
TProps. A published plugin cannot know the consumer's props shape in advance, so every plugin
author wrote either an untyped plugin (definePlugin({ … }), TProps defaulting to unknown, no
typed ctx.dataset read or write) or definePlugin<TaskProps>({ … }) naming a consumer type the
plugin itself has no business knowing.
Dataset<TProps> and Gantt<TProps> both carry #private members, so TypeScript compares two
instances of either by their type argument. A DataPlugin<LockProps> installed on a
Dataset<TaskProps> then failed to compile (TS2375), because Dataset<LockProps> (the type
ctx.dataset resolves to inside that plugin's data()) is not assignable to Dataset<TaskProps>
(the type the install site expected). The only way around it, in harness/plugins/lock-entries.ts
and the chrome plugins harness/plugins/buffer-kind.ts/risk-kind.ts, was a hand-written
as EntryEdit cast on every write — the harness workaround the project's stop rule exists to
surface, not hide.
Decision
A plugin's TProps argument names the keys that plugin itself reads and writes. It says nothing
about a consumer's own props, and a consumer's props say nothing about a plugin's.
Dataset<TProps> holds every installed plugin with its props erased:
// DatasetOptions.plugins — before and after
plugins?: readonly PluginOf<unknown, Dataset<TProps>>[]; // before: tied to the consumer's TProps
plugins?: readonly PluginOf<unknown, unknown>[]; // after: each plugin's props are its own
Gantt<TProps> does the same for a chrome plugin — GanttOptions.plugins, the plugins getter and
setter, installPlugin, hasPlugin, uninstallPlugin, and the internal assertChromeOnly guard all
take ChromePlugin with no type argument in place of ChromePlugin<TProps>.
data/view are method signatures, so TypeScript compares their parameter types bivariantly: a
context built for one TProps still satisfies a method declared to want another. So ctx.dataset
inside a plugin's own data(ctx) still reads and writes only that plugin's own declared keys —
ctx.dataset.entries.update('t1', { locked: true }) compiles inside a definePlugin<LockProps> call,
and { cost: 1 } (a consumer key LockProps never named) does not.
DataPluginOf.fields narrows each declared key to the plugin's own props:
// before // after
fields?: readonly Field[]; fields?: readonly (Field & { key: keyof PropsOf<TDataset> & string })[];
PropsOf<TDataset> reads the props a TDataset type argument carries off its own entries
collection, and falls back to Record<string, unknown> for an untyped plugin, so an untyped
definePlugin({ … }) still names any key. A typed plugin does not: definePlugin<LockProps>({ id, fields: [{ key: 'lockd' }] }) fails to compile, because 'lockd' is not a key of LockProps.
Not a lying generic
A plugin's type argument narrowing to its own keys, with no check against the consumer's, is not a
weaker promise than the one Dataset<TProps> already makes:
- The caller of
definePlugin<LockProps>is the plugin author, andLockPropstypes only what that author's own code reads and writes. No library code readsLockPropsto decide anything at runtime. - Every
Dataset<X>is a view onto one monomorphic store —data/holdsEntry<unknown>throughout, by design, with no static dependency on any one consumer's props shape.Dataset<LockProps>is the same object seen through a narrower key set.entry.propsisPartial<…>on every door, so a narrower view never promises a key is present. - The compile-time check this record adds stops the one lie a plugin author can type by accident: a
fieldskey that is not in their own declared props. - The runtime still refuses the other lie. A key in a plugin's
TPropsthat nothing declares throwsUnknownFieldErroratentries.update()— the same guaranteeDataset<TProps>itself already gives a consumer. - Before this record the generic lied in the other direction:
definePlugin<TaskProps>told a plugin author it saw the consumer's own keys, but a plugin published for any consumer cannot know them in advance.
Consequences
harness/plugins/lock-entries.tswrites itslockedField throughdataset.entries.update(id, { locked: true })with no cast.LockPropsis exported, so a consumer that wants a typed read adds it to its own props:Dataset<TaskProps & LockProps>.harness/plugins/buffer-kind.tsandrisk-kind.tswriteconsumed/acceptedthe same way, with no cast. Both stay chrome plugins (a chrome plugin cannot declare a Field), so the page that installs them still declares those two keys itself, in its ownfieldslist —entries.update()refuses either key if the page does not.Dataset.plugins,DatasetOptions.plugins,GanttOptions.plugins,Gantt.plugins,installPlugin,hasPluginanduninstallPluginall change their published type. No shape changes — every plugin that compiled before still compiles, and every value that installed before still installs.
Rejected alternatives
- Infer a plugin's
TPropsfrom its ownfieldsarray. AhierarchySource-only plugin declares nofieldsat all, so inference would answerunknownfor a plugin whose author wrote a real props type. A caller who writesdefinePlugin<LockProps>(...)explicitly would also fight inference for no reason. - The consumer writes the intersection at the install site (
Dataset<TaskProps & LockProps>to installlockEntries()). This is still legal, and still how a consumer gets a typed read back through its own Dataset. But requiring it to install the plugin at all would force every consumer to know and restate every plugin's own props, which is exactly the coupling a published plugin cannot assume. - A reverse check — every key in a plugin's props must appear in its
fields. This would refuse a plugin typed with a broader props shape than the one Field it happens to declare this release, and it would break every consumer's own app-local plugin typed against its own props for convenience. It also cannot run whenTPropsis written explicitly, because TypeScript then infers nothing to check it against. The runtime already refuses an undeclared key withUnknownFieldError, so no promise is lost by not adding this check.
Out of scope
Value-type propagation — checking a Field's declared type against the matching key's type in a
plugin's own TProps — stays open. ADR 0005
already tracks it; this record checks a fields key's name, never its type.