Infer the transform signature from the rune's own declarations
transform(resolved, attrs, config) is the one place a rune does real work, and both of its interesting parameters are untyped:
transform(resolved, attrs, config) is the one place a rune does real work, and both of its interesting parameters are untyped:
transform: (resolved: ResolvedContent, attrs: Record<string, any>, …)
attrs.nmae is not merely unchecked — it is any, so it poisons everything downstream. ResolvedContent is { [fieldName: string]: ResolvedField } where ResolvedField unions with unknown and collapses to it, so resolved.anythingWhatsoever is legal on all 121 runes.
Nothing new needs to be declared. required: true, matches: [...], name: 'body', greedy: true, optional: true are already written in every rune. ContentModelSchemaOptions widens them away. Make the builder generic with const type parameters (TS 5.0+; the repo is on 5.9.3) and the types fall out of declarations that already exist.
Prototyped and measured in spike/rune-typing/ — machinery, compiling cases, self-asserting negatives, and a compile-time benchmark. node check.mjs.
Four costs, in rough order of how much they hurt:
Typos are silent. A misspelled attribute or resolved field is any / unknown, never an error.
Shape assumptions are invisible. resolveSections returns two structurally different things from one model type — Ast.Node[] when emitTag is set (resolver.ts:502), and resolved entries carrying $heading, $canonicalName and the recursed body when it is not (:536). The runes split 6/8 on this. Nothing in the types says which you have.
Consumers re-assert by hand what the model already declares:
// plugins/plan/src/util.ts:27 export function buildSections(sections: any[], config: any): any[] { const headingText = section.$heading as string; const canonicalName = section.$canonicalName as string | undefined; const canonicalSlug = section.$canonicalSlug as string | undefined;
Enumerations decay to string. $canonicalName is one of the rune's declared known-section names or undefined. Read as string | undefined, a comparison against a typo — or against a section name another rune declares — is a silent no-op.
| Case | Verdict |
|---|---|
Attributes: required/optional, matches → union, base merge, the .slice() idiom | infers |
sequence: field names, greedy → array, optional → | undefined | infers |
sections with emitTag → Node[] | infers |
sections without emitTag → entries, recursing into sectionModel | infers |
delimited + dynamicZones → recursing into zoneModel | infers |
knownSections → $canonicalName as a literal union | infers |
knownSections with a per-section model: | infers; message is bad |
custom, headingExtract | opaque — unchanged from today |
The worry going in was that content models would be hard: four kinds, and they nest. Both halves were wrong. Recursion costs nothing structurally — conditional types recurse — and the variety maps to a conditional chain. The difficulty is one optional field that changes a return shape (emitTag) and one override nobody uses (model:).
Decorators or a model class would move the declaration out of plain-object land. refrakt inspect, refrakt contracts, the rune catalog and the attribute reference all read these schemas as runtime data; decorator metadata is harder to reflect over and serialise than an object literal. The plain-object form is load-bearing, and the information is already in it.
Record<never, never>Not Record<string, never>, which carries a string index signature: with it, any rune omitting base/attributes type-checks every attribute access and the feature silently does nothing. The spike caught this mid-flight — three expectations stopped firing while the resolved ones kept working, which is exactly how this would ship looking like a success. A regression test covers it.
With D2's defaults, an untouched rune keeps today's loose types. No cutover, no flag day. Start with plugins/learning (2 runes, both plain sequence), then re-measure before going wider.
as const; nothing else changesconst type parameters do not reach through a function's return type, so contentModel: () => ({…}) widens optional: true to boolean and loses field-level inference. One trailing as const fixes it, replacing the two or more inner as consts those files already carry. The other 106 content models need no call-site change at all.
custom and headingExtract stay opaqueprocessChildren is an arbitrary function (14 runes) and headingExtract derives keys from a runtime pattern. Both keep today's loose type. That is not a regression, and template-literal gymnastics for headingExtract would not pay for themselves.
KnownSectionDefinition.model is decided separately: use it or lose itmodel lets a known section resolve its body against a different content model than its siblings (resolver.ts:518). No rune declares one, and it was not among the three purposes WORK-024 added knownSections for — validation, aliases and editor templates. It arrived with the type.
It is also the only place the inferred type is unpleasant rather than merely verbose: the error inlines the whole model, truncated, and neither a named SectionEntry<M> alias nor hoisting the model to a named const improves it (both tried in the spike).
But there is a real latent use, so this spec does not propose removing it. work's Acceptance Criteria is semantically a checklist, and the plan pipeline currently counts it by regex over rendered text:
// plugins/plan/src/pipeline.ts const unchecked = (text.match(/\[ \]/g) || []).length; const checked = (text.match(/\[x\]/gi) || []).length;
A literal [x] anywhere in a work item inflates that count. A per-section model resolving criteria as a structured list is the right fix, and it is exactly what this field is for.
So the decision is use it or lose it, and it belongs to the plan runes, not to this spec. What it must not do is stay unused and impose the worst error messages in the scheme. Either outcome is compatible with the types here: the union is correct when a model exists and collapses to a single shape when none does.
The spike's +20–28% is 121 runes in the heaviest shape, against a synthetic file. The real mix is 87 sequence, 13 sections, 3 with knownSections, so the true figure should be lower — but this is a library whose types consumers' builds instantiate too, and a synthetic benchmark is not evidence about their builds.
config (the Markdoc Config) or the node parametercustom models (D5)createContentModelSchema is generic over base, attributes and contentModel, with const type parametersattrs resolves required attributes as present, optional as | undefined, matches as a literal union, and String/Number/Boolean as their primitivesresolved resolves sequence field names, with greedy as an array and optional as | undefinedresolved.sections is Node[] when emitTag is declared and resolved entries when it is notsectionModel, and a delimited zone's through zoneModel$canonicalName is the literal union of the rune's declared known-section namesRecord<never, never>, with a regression test that a rune declaring no attributes still rejects an unknown one (D2)as const on the returned object; the other 106 are untouched (D4)custom models resolve to the loose type, with no error (D5)spike/rune-typing/check.mjs passes against the shipped types, not just the prototypebuildSections (plugins/plan/src/util.ts:27) drops its any[] parameter and its six hand-written castsas const rule for thunked modelsspike/rune-typing/ — the prototype, cases, negatives and benchmark this spec rests onproperties and refs share, which the inferred types must keep enforcing