Model 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; run
deno test --allow-env --allow-sys --allow-read — 6 green tests
prove the machinery before you touch anything.
- Rename:
name in deno.json; MyLib → YourLib in class
names and filenames; myLibEntry → yourLibEntry; then
src/lib.ts — LIB_MODULE (emitted module specifier) and LIB
(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.ts to
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.
MyLibRef puts only the peer's
NAME in the value tree; the ModelDriver resolves 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
> 1 and sets settings.identifier.typeName (SLOT
recursion-annotation) so the emitted export const doesn't die of
circular inference (TS7022/7024). Self-recursion only — mutual
recursion is not detected.
- Optional and nullable are different axes.
required comes from
the PARENT object's required list and flows into each property
leaf's modifiers; nullable sits on the node itself. Both render
exactly once, in applyModifiers, 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-blind toString() cannot serve
both (compiler-verified 2026-08-04, kotlin-debug rig): properties-only
declares as a data class parameter list, and in type position must
render a NAME — synthesize the named sibling declaration and
reference it (name derived from the schema's own stackTrail, 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-jackson
toSynthesizedName.ts + synthesizedNames.ts; a parameter list in
type position parses as a function type and fails, and widening to
Map<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 is typealias); both-forms has a declaration
form (data class plus a @field:JsonAnySetter @get:JsonAnyGetter
catch-all map property) but no anonymous type form. Decide the
identifier KIND and the value together from the same schema guards
(gen-kotlin-jackson shape.ts is the worked example) — never from
the name alone, and never by making one toString() 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 named
sealed declaration, 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), not Any. Full pattern: the Kotlin lang
skill §8c.
- Property keys go through
handleKey — 'first-name' renders
quoted; never assume keys are identifiers.
- Visibility.
readOnly/writeOnly are captured per property in
MyLibObjectProperties.visibility. Default policy ignores them; if
the target needs them, annotate the value (e.g. .readonly()) or
emit request/response variants via variant threading — decide at
SLOT(visibility).
- Unknown never throws. Untyped schemas route to the unknown
fallback so one odd schema can't kill the subject.
custom values
pass through untouched.
- TypeSystem contracts. Each snippet class carries the fields peers
rely on (
TypeSystemString needs format + enums; objects expose
objectProperties/recordProperties). Add fields freely; remove
none — removal breaks insertNormalizedModel consumers and fails the
SchemaToValueFn check.
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).
1---2name: skmtc-model3description: 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 cases every model generator must survive — refs, recursion, optional vs nullable, additionalProperties, enums, readOnly/writeOnly. Use when authoring or editing a generator that maps schemas to a validator/schema/type library ("write a gen-<lib>", "map OpenAPI models to <lib>"). Load ALONGSIDE skmtc-generator (engine rules) and skmtc-lang-typescript (TS layer).4---56# Model generators: fill the skeleton78A **model generator** turns each component schema (`refName`) into one9definition in one file: entry → projection → schema-type router → one10snippet class per schema type. That structure is invariant across11target libraries — only naming policy and per-type syntax vary. So do12not write the structure: copy it.1314## 1. The method1516The `skeleton/` directory next to this file is a complete, compiling,17engine-tested model generator that renders a placeholder syntax18(`m.object({...})`). Author by transplant, not from scratch:19201. **Copy** `skeleton/` to your package location; run21 `deno test --allow-env --allow-sys --allow-read` — 6 green tests22 prove the machinery before you touch anything.232. **Rename**: `name` in `deno.json`; `MyLib` → `YourLib` in class24 names and filenames; `myLibEntry` → `yourLibEntry`; then25 `src/lib.ts` — `LIB_MODULE` (emitted module specifier) and `LIB`26 (imported symbol).273. **Fill the slots** (§2), smallest first: scalars → string/enum →28 array/object → union → lazy/recursion annotation.294. **Re-pin the test**: update the pinned strings in `mod.test.ts` to30 your target syntax. The structural assertions (files exist, shared31 refs dedup to ONE definition, import headers stitched, recursion32 annotated) must pass UNCHANGED — if one breaks, you broke machinery,33 not syntax.3435Every slot is a `// SLOT(name):` comment. Everything outside a SLOT is36engine machinery — modifying it is almost always a mistake.3738## 2. The slots3940| Slot | File | Decision |41|---|---|---|42| `library` | `src/lib.ts` | emitted module + symbol, single point |43| `naming`, `identifier-kind`, `export-path` | `src/base.ts` | identity policy (from `refName` ONLY) |44| `string`, `string-constraints` | `src/MyLibString.ts` | string / enum / literal syntax; formats, min/maxLength |45| `number`, `integer`, `boolean`, `unknown`, `void` | `src/MyLibScalars.ts` | scalar syntax; numeric constraints |46| `array` | `src/MyLibArray.ts` | list syntax |47| `object-properties`, `object-intersection`, `object-empty`, `visibility` | `src/MyLibObject.ts` | object syntax; properties+record composition; readOnly/writeOnly policy |48| `record` | `src/MyLibObject.ts` | additionalProperties map syntax |49| `union` | `src/MyLibUnion.ts` | oneOf/anyOf; discriminated form |50| `lazy` | `src/MyLibRef.ts` | deferred-reference form for cycles |51| `recursion-annotation` | `src/MyLibProjection.ts` | type annotation breaking circular inference |52| `modifiers` | `src/modifiers.ts` | optional/nullable syntax and wrap order |53| `enrichments` | `src/enrichments.ts` | config seam (default: opt-out) |5455## 3. Edge cases the skeleton already handles — keep them working5657- **Refs are names, never expansions.** `MyLibRef` puts only the peer's58 NAME in the value tree; the `ModelDriver` resolves the definition59 (cache hit → reuse, miss → construct) and stitches the cross-file60 import. Inline-expanding a ref, or hand-writing its import, is how61 shared models duplicate.62- **Recursion is a protocol, not a special case.** A back-reference to63 a model still open on the build stack (`context.modelDepth` > 0)64 renders via SLOT(lazy) and bumps the depth; the projection then sees65 `> 1` and sets `settings.identifier.typeName` (SLOT66 recursion-annotation) so the emitted `export const` doesn't die of67 circular inference (TS7022/7024). Self-recursion only — mutual68 recursion is not detected.69- **Optional and nullable are different axes.** `required` comes from70 the PARENT object's `required` list and flows into each property71 leaf's `modifiers`; `nullable` sits on the node itself. Both render72 exactly once, in `applyModifiers`, at the leaf — no other owner, and73 never while building stored fields.74- **additionalProperties** → the record path; `true`/empty schema →75 the unknown fallback; properties + additionalProperties together →76 SLOT(object-intersection).77- **An object schema has four forms — and position can change the78 render.** Properties-only, record-only (additionalProperties), both,79 empty: every place an object renders must survive all four. In80 TypeScript one expression serves both type and declaration positions81 (`z.object({...})`, `.and(z.record(...))` for both-forms), so the82 object SLOTs compose freely. In a head+value language (Kotlin) the83 two positions DIVERGE, and a position-blind `toString()` cannot serve84 both (compiler-verified 2026-08-04, kotlin-debug rig): properties-only85 declares as a `data class` parameter list, and in type position must86 render a NAME — synthesize the named sibling declaration and87 reference it (name derived from the schema's own `stackTrail`, no88 naming param threaded through the router; collisions policed by a89 document-wide claim registry that throws per-item, since the name90 shares a PACKAGE with every component class — gen-kotlin-jackson91 `toSynthesizedName.ts` + `synthesizedNames.ts`; a parameter list in92 type position parses as a function type and fails, and widening to93 `Map<String, Any?>` discards the type — capitulation, not a94 solution); record-only and empty95 must not take a data-class head at all (`data class X()` is illegal —96 their declaration kind is `typealias`); both-forms has a declaration97 form (data class plus a `@field:JsonAnySetter @get:JsonAnyGetter`98 catch-all map property) but no anonymous type form. Decide the99 identifier KIND and the value together from the same schema guards100 (gen-kotlin-jackson `shape.ts` is the worked example) — never from101 the name alone, and never by making one `toString()` answer both102 positions.103- **A discriminated union may be a DECLARATION, not an expression.**104 In TypeScript SLOT(union) is one expression105 (`z.discriminatedUnion("type", [...])`). In a language without union106 types (Kotlin) a qualifying discriminated union becomes a named107 `sealed` declaration, and the member models must declare the108 supertype — a member may be BUILT before its union is ever seen, so109 membership comes from a document-wide scan (parent → member110 inversion, WeakMap-memoized) consulted at member construction, never111 from the union's own walk. Non-qualifying unions render the honest112 wire type (`JsonNode`), not `Any`. Full pattern: the Kotlin lang113 skill §8c.114- **Property keys** go through `handleKey` — `'first-name'` renders115 quoted; never assume keys are identifiers.116- **Visibility.** `readOnly`/`writeOnly` are captured per property in117 `MyLibObjectProperties.visibility`. Default policy ignores them; if118 the target needs them, annotate the value (e.g. `.readonly()`) or119 emit request/response variants via `variant` threading — decide at120 SLOT(visibility).121- **Unknown never throws.** Untyped schemas route to the unknown122 fallback so one odd schema can't kill the subject. `custom` values123 pass through untouched.124- **TypeSystem contracts.** Each snippet class carries the fields peers125 rely on (`TypeSystemString` needs `format` + `enums`; objects expose126 `objectProperties`/`recordProperties`). Add fields freely; remove127 none — removal breaks `insertNormalizedModel` consumers and fails the128 `SchemaToValueFn` check.129130## 4. Verify131132The shipped `mod.test.ts` runs the REAL pipeline (`toArtifacts`) over a133fixture with an enum, an array-of-ref, a shared ref (×2 → one134definition), optional + nullable, a record, and a self-recursive model.135It is your regression gate: green before you start, green after every136slot. Read failures in this order: import header first (a missing137import means a string swallowed a snippet), then the body, then138`deno lint` (the `skmtc/*` rules are wired in `deno.json`).139140## 5. Model-generator pitfalls141142| Symptom | Fix |143|---|---|144| Shared model duplicated per consumer | A ref was rendered/expanded instead of flowing through `MyLibRef` |145| Stack overflow on recursive schema | The `modelDepth` branch in `MyLibRef` was removed or bypassed |146| Emitted file dies of TS7022/7024 | SLOT(recursion-annotation) not set for the target |147| `.optional()` doubled or missing | Modifiers applied outside `applyModifiers`, or a second owner added |148| Enum with `null` member renders `'null'` | Keep the `literal()` null-guard from `MyLibString` |149| Peer generator can't consume yours | `schemaToValueFn`/`createIdentifier` statics or TypeSystem contract fields removed |150| Lint fires `no-template-imports`/`no-adhoc-tostring` | Target syntax leaked outside a `toString()` body — move it into the SLOT |151| `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 |152153## 6. Boundaries154155Engine semantics (the one law, memoization, enrichments, variants,156naming rules) live in **skmtc-generator** — read it first. TS-layer157specifics (register shapes, identifier kinds, import machinery,158`List`/`FunctionParameter`) live in **skmtc-lang-typescript**. This159skill owns only the model SHAPE. The skeleton is TypeScript-emitting;160for a Kotlin model generator, keep this skill's shape and edge-case161rules but take call shapes from the Kotlin lang skill (no Kotlin162skeleton yet). Operation generators are a different shape — load163`skmtc-operation`; accumulators are covered by neither (clone164`gen-msw`/`gen-express` per skmtc-generator §2).