skmtc-model
The model-generator shape for Skmtc: one definition per component schema, built by copying the shipped SKELETON package and filling its SLOT markers with the target library's syntax. Covers the edge c
SKILL.mddeno/docs/skills/skmtc-model/SKILL.md
GitHubModel generators: fill the skeleton
A model generator turns each component schema (refName) into one
definition in one file: entry → projection → schema-type router → one
snippet class per schema type. That structure is invariant across
target libraries — only naming policy and per-type syntax vary. So do
not write the structure: copy it.
1. The method
The skeleton/ directory next to this file is a complete, compiling,
engine-tested model generator that renders a placeholder syntax
(m.object({...})). Author by transplant, not from scratch:
- Copy
skeleton/to your package location; rundeno test --allow-env --allow-sys --allow-read— 6 green tests prove the machinery before you touch anything. - Rename:
nameindeno.json;MyLib→YourLibin class names and filenames;myLibEntry→yourLibEntry; thensrc/lib.ts—LIB_MODULE(emitted module specifier) andLIB(imported symbol). - Fill the slots (§2), smallest first: scalars → string/enum → array/object → union → lazy/recursion annotation.
- Re-pin the test: update the pinned strings in
mod.test.tsto your target syntax. The structural assertions (files exist, shared refs dedup to ONE definition, import headers stitched, recursion annotated) must pass UNCHANGED — if one breaks, you broke machinery, not syntax.
Every slot is a // SLOT(name): comment. Everything outside a SLOT is
engine machinery — modifying it is almost always a mistake.
2. The slots
| Slot | File | Decision |
|---|---|---|
library |
src/lib.ts |
emitted module + symbol, single point |
naming, identifier-kind, export-path |
src/base.ts |
identity policy (from refName ONLY) |
string, string-constraints |
src/MyLibString.ts |
string / enum / literal syntax; formats, min/maxLength |
number, integer, boolean, unknown, void |
src/MyLibScalars.ts |
scalar syntax; numeric constraints |
array |
src/MyLibArray.ts |
list syntax |
object-properties, object-intersection, object-empty, visibility |
src/MyLibObject.ts |
object syntax; properties+record composition; readOnly/writeOnly policy |
record |
src/MyLibObject.ts |
additionalProperties map syntax |
union |
src/MyLibUnion.ts |
oneOf/anyOf; discriminated form |
lazy |
src/MyLibRef.ts |
deferred-reference form for cycles |
recursion-annotation |
src/MyLibProjection.ts |
type annotation breaking circular inference |
modifiers |
src/modifiers.ts |
optional/nullable syntax and wrap order |
enrichments |
src/enrichments.ts |
config seam (default: opt-out) |
3. Edge cases the skeleton already handles — keep them working
- Refs are names, never expansions.
MyLibRefputs only the peer's NAME in the value tree; theModelDriverresolves the definition (cache hit → reuse, miss → construct) and stitches the cross-file import. Inline-expanding a ref, or hand-writing its import, is how shared models duplicate. - Recursion is a protocol, not a special case. A back-reference to
a model still open on the build stack (
context.modelDepth> 0) renders via SLOT(lazy) and bumps the depth; the projection then sees> 1and setssettings.identifier.typeName(SLOT recursion-annotation) so the emittedexport constdoesn't die of circular inference (TS7022/7024). Self-recursion only — mutual recursion is not detected. - Optional and nullable are different axes.
requiredcomes from the PARENT object'srequiredlist and flows into each property leaf'smodifiers;nullablesits on the node itself. Both render exactly once, inapplyModifiers, at the leaf — no other owner, and never while building stored fields. - additionalProperties → the record path;
true/empty schema → the unknown fallback; properties + additionalProperties together → SLOT(object-intersection). - An object schema has four forms — and position can change the
render. Properties-only, record-only (additionalProperties), both,
empty: every place an object renders must survive all four. In
TypeScript one expression serves both type and declaration positions
(
z.object({...}),.and(z.record(...))for both-forms), so the object SLOTs compose freely. In a head+value language (Kotlin) the two positions DIVERGE, and a position-blindtoString()cannot serve both (compiler-verified 2026-08-04, kotlin-debug rig): properties-only declares as adata classparameter list, and in type position must render a NAME — synthesize the named sibling declaration and reference it (name derived from the schema's ownstackTrail, no naming param threaded through the router; collisions policed by a document-wide claim registry that throws per-item, since the name shares a PACKAGE with every component class — gen-kotlin-jacksontoSynthesizedName.ts+synthesizedNames.ts; a parameter list in type position parses as a function type and fails, and widening toMap<String, Any?>discards the type — capitulation, not a solution); record-only and empty must not take a data-class head at all (data class X()is illegal — their declaration kind istypealias); both-forms has a declaration form (data class plus a@field:JsonAnySetter @get:JsonAnyGettercatch-all map property) but no anonymous type form. Decide the identifier KIND and the value together from the same schema guards (gen-kotlin-jacksonshape.tsis the worked example) — never from the name alone, and never by making onetoString()answer both positions. - A discriminated union may be a DECLARATION, not an expression.
In TypeScript SLOT(union) is one expression
(
z.discriminatedUnion("type", [...])). In a language without union types (Kotlin) a qualifying discriminated union becomes a namedsealeddeclaration, and the member models must declare the supertype — a member may be BUILT before its union is ever seen, so membership comes from a document-wide scan (parent → member inversion, WeakMap-memoized) consulted at member construction, never from the union's own walk. Non-qualifying unions render the honest wire type (JsonNode), notAny. Full pattern: the Kotlin lang skill §8c. - Property keys go through
handleKey—'first-name'renders quoted; never assume keys are identifiers. - Visibility.
readOnly/writeOnlyare captured per property inMyLibObjectProperties.visibility. Default policy ignores them; if the target needs them, annotate the value (e.g..readonly()) or emit request/response variants viavariantthreading — decide at SLOT(visibility). Caller options are the third route: declare the second type parameter oftoTsModelProjectionBase, let the calling generator pass{ options }on the insert, and fold them into the name. - Unknown never throws. Untyped schemas route to the unknown
fallback so one odd schema can't kill the subject.
customvalues pass through untouched. - TypeSystem contracts. Each snippet class carries the fields peers
rely on (
TypeSystemStringneedsformat+enums; objects exposeobjectProperties/recordProperties). Add fields freely; remove none — removal breaksinsertNormalizedModelconsumers and fails theSchemaToValueFncheck.
4. Verify
The shipped mod.test.ts runs the REAL pipeline (toArtifacts) over a
fixture with an enum, an array-of-ref, a shared ref (×2 → one
definition), optional + nullable, a record, and a self-recursive model.
It is your regression gate: green before you start, green after every
slot. Read failures in this order: import header first (a missing
import means a string swallowed a snippet), then the body, then
deno lint (the skmtc/* rules are wired in deno.json).
5. Model-generator pitfalls
| Symptom | Fix |
|---|---|
| Shared model duplicated per consumer | A ref was rendered/expanded instead of flowing through MyLibRef |
| Stack overflow on recursive schema | The modelDepth branch in MyLibRef was removed or bypassed |
| Emitted file dies of TS7022/7024 | SLOT(recursion-annotation) not set for the target |
.optional() doubled or missing |
Modifiers applied outside applyModifiers, or a second owner added |
Enum with null member renders 'null' |
Keep the literal() null-guard from MyLibString |
| Peer generator can't consume yours | schemaToValueFn/createIdentifier statics or TypeSystem contract fields removed |
Lint fires no-template-imports/no-adhoc-tostring |
Target syntax leaked outside a toString() body — move it into the SLOT |
data class NameMap<String, Any?> (head glued to a type) in output |
Declaration kind and value were decided separately — see the four-forms bullet in §3; kind+value must come from the same schema guards |
6. Boundaries
Engine semantics (the one law, memoization, enrichments, variants,
naming rules) live in skmtc-generator — read it first. TS-layer
specifics (register shapes, identifier kinds, import machinery,
List/FunctionParameter) live in skmtc-lang-typescript. This
skill owns only the model SHAPE. The skeleton is TypeScript-emitting;
for a Kotlin model generator, keep this skill's shape and edge-case
rules but take call shapes from the Kotlin lang skill (no Kotlin
skeleton yet). Operation generators are a different shape — load
skmtc-operation; accumulators are covered by neither (clone
gen-msw/gen-express per skmtc-generator §2).