The Kotlin layer (@skmtc/lang-kotlin)
Read skmtc-generator first.
Drift warning. The API of record is the workspace
skmtc/deno/lang-kotlin and its tests. The shipped gen-kotlin-*
generators predate the 0.9.11 flattening: they call
new KtAnnotation('Name', [args]) positionally and import
isKtAnnotated/isKtSupertyped (no longer exported; supertype
clauses now render inline in the value). Clone their structure
only; take call shapes from THIS skill's example (§8), which is pinned
byte-for-byte against the engine by
lang-kotlin/src/skill-example.test.ts.
1. Declaring the language
Same pattern as TypeScript — the import graph declares it. Two
factories: toKtModelProjectionBase, toKtOasOperationProjectionBase;
snippets extend KtSnippet.
export const KtModelBase = toKtModelProjectionBase<EnrichmentSchema>({
id: denoJson.name,
toEnrichmentSchema,
toIdentifierName({ refName, enrichments }) {
return enrichments?.subject?.name ?? capitalize(camelCase(refName))
},
// Kotlin's identifier KIND depends on schema shape → may read context
// (runs only on cache-miss; the NAME stays pure):
toIdentifierType(refName, context) {
return { type: toShape(context, peekSchema(context, refName)) }
},
toExportPath({ refName, enrichments }) {
const name = enrichments?.subject?.name ?? capitalize(camelCase(refName))
return join('@', ...enrichments.generator.basePackage.split('.'), `${name}.generated.kt`)
}
})
The export path's directory segments ARE the Kotlin package (§4). Make
basePackage a required generator-scope enrichment with no default;
validate segments with isKtIdentifierName + ktHardKeywords. Put the
shape dispatch (object+props → data-class; string+enums →
enum-class; qualifying discriminated union → sealed-interface; else
typealias) in ONE deterministic function read by both
toIdentifierType and the constructor, so kind and value can't disagree.
2. Register shapes — Kotlin differences
Same three shapes as TS (projection own-file / registerInto / snippet
with required destinationPath), plus defineAndRegister (no cache
check; no noExport — visibility is the identifier's fact: pass
exported: false to the factory). Compile-time differences: no
reExports field (Kotlin has none) and no type tag on imports
(no type-only imports). custom renders above the package directive.
3. Identifier kinds
Kotlin output has seven entity kinds (KtEntityType): class,
data-class, enum-class, interface, sealed-interface,
typealias, val —
factories createClass, createDataClass, createEnumClass,
createInterface, createSealedInterface, createTypeAlias,
createValue (only createValue takes typeName; exported: false
renders private ). Deferred kinds (object, fun, var) make
toKtEntityType throw — deliberately loud. Kind does NOT affect import
form. The engine's type is an opaque string: isKtEntityType narrows
it to the vocabulary above, and isKtIdentifier narrows a neutral
IdentifierBase back to KtIdentifier.
4. Emitted-import rules
- Packages from paths:
@/com/example/api/User.generated.kt →
package com.example.api. Segments are validated, never sanitized —
a keyword or invalid segment throws (fix the path policy).
- One
import pkg.Name per symbol (no brace grouping), as aliases,
rendered sorted (determinism, not style).
- Same-package suppression is central: register imports
unconditionally;
KtFile drops same-package ones at render.
- Importing from the default package throws (root-level artifact
referenced from a packaged bug).
5. Render model: head + value
Assignment kinds (typealias, val): <head> = <value>. Declaration
kinds: <head><value> — the value renders everything after the name:
parameter list (parens included), inline : Parent clauses, { … }
bodies; an empty value yields the bodyless idiom
(sealed interface Animal).
Two things ride on value-carried protocols (the neutral Lang signature
has no slot for them): KtAnnotated (annotations: KtAnnotation[],
strict — string look-alikes are silently dropped) and KtDocumented
(description, guard isKtDocumented, rendered as KDoc above the
annotations). The mirroring gotcha: the Driver wraps the
PROJECTION as the definition's value, so mirror both onto the projection
— canon is reference assignment in the constructor
(this.annotations = this.value.annotations — one array, two names;
never copy) — or class-level annotations and KDoc silently vanish.
6. Composition classes (current API)
KtParameterList(parameters) — parens included; each
{ name, type: Stringable, nullable?, defaultValue?, annotations?, visibility? } renders as an indented val, annotations one per line.
KtPrimaryConstructor({ parameters, modifiers? }) — modifiers force
the explicit constructor keyword.
KtFunctionSignature({ name, parameters, returnType?, annotations?, body? }) — abstract by default, expression body only.
KtAnnotation({ context, name, args?, target?, packageName?, destinationPath }) — a registering leaf: with packageName it
registers its own import (register unconditionally; suppression
handles same-package). args are pre-quoted (['"user_id"'],
['Foo::class']). target is the use-site target
(KtAnnotationTarget: field/get/set/…) rendered as
@field:JsonAnySetter — the imported symbol stays the bare name.
Needed on a constructor val, which is parameter/property/field/
getter at once: Jackson's catch-all pair is @field:JsonAnySetter +
@get:JsonAnyGetter, and without targets both annotations land on
the parameter, where Jackson never looks. (Shipped in lang-kotlin
0.10.0, 2026-08-04 — pre-target versions cannot express use-site
targets at all.)
withDescription(value, { description }) — KDoc.
7. Sanitization and @SerialName
sanitizePropertyName(name): plain → unchanged; hard keyword or invalid
→ backticked; JVM-unescapable characters → throws ("rename +
@SerialName"). Renames are NOT its job — serialization annotations
handle wire-name mismatches, and the two compose: decide the annotation
by comparing the unescaped chosen name with the wire key
(`object` needs no @SerialName; user_id→userId does). Only the
28 hard keywords escape; soft/modifier keywords (value, data,
sealed) are legal identifiers. Canonical pairing:
sanitizePropertyName(camelCase(key)).
8. Worked example — kotlinx data class (current API, engine-pinned)
Per-property loop inside the data-class value snippet:
const propertyName = sanitizePropertyName(camelCase(key))
const annotations: KtAnnotation[] = []
if (propertyName.replaceAll('`', '') !== key) {
annotations.push(new KtAnnotation({
context, destinationPath,
name: 'SerialName', packageName: 'kotlinx.serialization', args: [`"${key}"`]
}))
}
parameters.push({
name: propertyName,
type: value, // the SNIPPET — never `${value}`
defaultValue: isRequired ? undefined : 'null',
annotations
})
// this.parameterList = new KtParameterList(parameters)
// class-level: this.annotations = [new KtAnnotation({ context,
// destinationPath, name: 'Serializable', packageName: 'kotlinx.serialization' })]
// projection mirrors by REFERENCE: this.annotations = this.value.annotations
Renders (verified byte-for-byte through the engine):
package com.example.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class User(
@SerialName("user_id")
val userId: String,
val name: String,
val email: String? = null
)
The type expression is the single owner of ?; the parameter layer
only adds = null. Passing `${value}` instead of the snippet
strands its registered imports and synthesized siblings — the file
breaks far from the cause. Serialization flavor is confined to the value
files (data class / enum entries / sealed interface): a Jackson/Moshi
sibling generator swaps annotation construction there only.
8b. Normalized models — KNOWN ENGINE GAP (verified 2026-08-03)
The head+value model means a Kotlin value renders differently in TYPE
position (Map<String, Any?>) and DECLARATION position (a parameter
list). Core's generic insertNormalizedModel glues the identifier
head to the value's type-position toString() — which for an inline
OBJECT schema renders invalid Kotlin: data class XMap<String, Any?>.
The engine gap is real, but the SOLUTION does not wait for it —
every mature Kotlin generator solves inline objects the same way:
- Named
$ref schemas are unaffected — insertModel and the ref
path work correctly.
- An inline NON-object schema normalizes fine as a
typealias-shaped
value.
- An inline object is SYNTHESIZED as a named sibling declaration
and referenced by name — the retired gen-kotlin-kotlinx pattern
(
KtObjectValue, skmtc-generators history at 2c24a65) rebuilt
WITHOUT its naming-hint threading: the name derives from the
schema's own stackTrail (toSynthesizedName.ts in
gen-kotlin-jackson — anchor on the components/paths landmark
frames, never absolute indices; classification is POSITIONAL:
properties consumes the following frame as a literal key, so a
property named properties/schema/items can never be mistaken
for trail structure), so every construction path — including peers
arriving through insertNormalizedModel — lands on the same name
with NO parameter added to the router contract. Names are NOT
collision-free: claim via the document-wide registry
(claimSynthesizedName, gen-kotlin-jackson synthesizedNames.ts)
BEFORE declaring — it throws per-item when the name collides with a
component-derived class name (Kotlin's redeclaration scope is the
PACKAGE, not the file) or with a different position's claim
(camelCase-convergent keys), and returns reuse for a same-position
re-walk. On 'declare', defineAndRegister the sibling and render
only the NAME. Type position then always holds a name or a map —
never property structure. This is also how OpenAPI Generator solves it
(inline schemas hoisted to named components before generation).
Widening a known shape to Map<String, Any?> is capitulation, not
a solution — it discards the type the schema gave you. Inline
string enums synthesize the same way (enum class sibling).
- Never fabricate a refName or drive the peer's identity statics to
force a declaration into existence — that is the two-doors rule
(skmtc-generator §4), and the result couples you to the peer's
private snippet shape.
8c. Discriminated unions — sealed interfaces (shipped 2026-08-04)
Kotlin has no union type; a QUALIFYING discriminated union becomes a
sealed interface (gen-kotlin-jackson is the worked example; ancestry:
the retired kotlinx machinery at skmtc-generators 2c24a65, stale call
shapes). Predicate (shape.ts isSealedUnion, part of the shape
dispatch): discriminated, ≥2 members, every member a $ref to an
object-with-properties, and every member keeps ≥1 parameter AFTER
discriminator omission. Everything else renders the honest wire type
(JsonNode for Jackson), never Any.
- The inversion scan. OpenAPI points parent → member; Kotlin
declares member → parent (
data class Dog(...) : Pet). Memoization
makes build order arbitrary, so membership must be known BEFORE any
construction: one document-wide scan over components.schemas,
memoized per document via WeakMap, mapping member refName → claims.
Claims store the parent's real RefName; the consumer derives the
display name via context.toModelContentSettings — never a copy of
the naming policy, never a fabricated refName.
- Parent side: an empty-body value (
toString() returns '' →
the bodyless idiom) carrying @JsonTypeInfo(use = NAME, include = PROPERTY, property = "<discriminator>") + @JsonSubTypes(Type(value = Dog::class, name = "dog"), …) via the KtAnnotated protocol —
mirror annotations AND description on the projection by
reference. Each subtype entry holds the walked member ref SNIPPET,
so member models build and imports stitch through the normal chain.
Tags: discriminator.mapping key pointing at the member, else the
member's refName (the OpenAPI default).
- Member side: inline
: Pet supertype clause rendered by the
parameter-list value (after the parens), and the discriminator
property OMITTED — filtered BEFORE the property walk, or its enum
schema synthesizes a spurious sibling. Same package by the
export-path policy satisfies Kotlin's sealed same-package rule.
- Jackson vs kotlinx flavor: tags are parent-side
(
@JsonSubTypes), so members carry no tag annotation and one member
may hold different tags under different parents (the kotlinx
one-@SerialName-per-class conflict rule does not apply).
- Runtime gotcha (probed): a raw
writeValueAsString(list) erases
the element type and silently DROPS the tags; concrete roots,
writerFor(type), and full-generic types all write them — Spring
MVC uses the typed path, so real consumers are fine. Test round-trips
with a typed writer.
allOf-composed members (the spec's canonical idiom: shared fields
on a base, members compose via allOf) qualify WITHOUT special
handling — core resolves allOf at parse time (mergeIntersection),
so the member peeks as a flat object and the base's fields flatten
into each data class (verified through the pipeline 2026-08-04).
Flattening is the right Kotlin target: the sealed interface is the
polymorphism seam, not class inheritance.
- Inline unions (stage 2, shipped): a qualifying union ANYWHERE —
component property, operation body/response/header/parameter —
synthesizes its sealed parent under its stackTrail name (combinator
frames
oneOf/anyOf/allOf are structural and elided; a
parameters/<index> position resolves to the parameter NAME via a
WeakMap document scan — the trail itself cannot carry names, it
doubles as a JSON Pointer where parameters is an array) into the
MODELS package (toModelExportPath — ONE placement policy for EVERY
synthesized declaration; caller's-file placement breaks
'reuse'-across-files for cross-package peers). The scan deep-walks
components AND operations AND webhooks (headers and the content
alternative included); synthesized claims carry the union NODE so
ensureSealedParent lets WHOEVER needs the name first declare it via
the claim registry. Derivability is ONE shared non-throwing probe
(toSynthesizedNameOrNull) across scan/render/members — underivable
roots degrade consistently to pre-synthesis behavior; the object/enum
sites deliberately keep the THROWING derivation (no honest fallback
exists for structure). One member may implement several sealed
parents (parent-side tags).
- Not yet built: undiscriminated unions (stage 3 —
enrichment-asserted hints / Jackson
Id.DEDUCTION) and the INVERTED
swagger-style pattern (discriminator on the base, no oneOf,
membership implied by allOf back-references — no union node exists,
so no sealed interface).
9. Kotlin pitfalls
| Symptom |
Fix |
@Serializable/KDoc missing |
Mirror annotations/description getters on the projection |
| Annotation silently dropped |
Real KtAnnotation instances, not strings |
segment 'x' is not a valid package name part |
Fix the export-path policy — packages validate, never sanitize |
| Import mid-file / duplicated |
register / annotation packageName, never templates |
String?? |
Type expression owns the single ? |
Unknown Kotlin entity type |
Use the seven Kotlin factories, not TS kinds |
Empty data class throws |
Shape dispatch must route empty objects to typealias |
| TDZ crash at module load |
Break base↔router↔projection cycles with a leaf module (peekSchema pattern) |
| Nondeterministic output |
No module state; config via enrichments; memoize document scans in WeakMap |
data class X glued to Map<String, Any?> |
The normalized-insert type/declaration gap — §8b, don't hack around it |
Union renders Any/JsonNode where a sealed type was expected |
Qualifying predicate failed — check discriminator presence, all-ref members, per-member surviving parameters (§8c) |
Member missing : Parent / spurious discriminator enum sibling |
Membership scan not consulted before construction, or omission applied after the property walk (§8c) |
| Sealed round-trip loses the wire tag at runtime |
Jackson root-list type erasure — serialize via a typed writer; generated code is correct (§8c) |
Appendix — generated API reference
The full deno doc surface for the packages this skill covers lives
in appendix.md, in this skill's directory —
generated from framework source — signatures and field docs only.
It is authoritative: when the prose above does
not carry the exact constructor or field shape you need, Read (or
grep) appendix.md instead of diving into package source. Do not
guess signatures. For a symbol not listed there,
deno doc <file> <Symbol> against the framework source beats
grepping it.
1---2name: skmtc-lang-kotlin3description: The Kotlin target-language layer for Skmtc generators (@skmtc/lang-kotlin): base factories, KtSnippet, the seven entity kinds, packages-from-paths imports, the head+value render model, KtAnnotation and the composition classes, sanitization and @SerialName placement, plus the current-API worked example (the shipped gen-kotlin-* packages are API-stale — do not copy their call shapes). Use ALONGSIDE skmtc-generator whenever a generator emits Kotlin. Headings mirror skmtc-lang-typescript.4---56# The Kotlin layer (@skmtc/lang-kotlin)78Read `skmtc-generator` first.910> **Drift warning.** The API of record is the workspace11> `skmtc/deno/lang-kotlin` and its tests. The shipped `gen-kotlin-*`12> generators predate the 0.9.11 flattening: they call13> `new KtAnnotation('Name', [args])` positionally and import14> `isKtAnnotated`/`isKtSupertyped` (no longer exported; supertype15> clauses now render inline in the value). Clone their **structure**16> only; take call shapes from THIS skill's example (§8), which is pinned17> byte-for-byte against the engine by18> `lang-kotlin/src/skill-example.test.ts`.1920## 1. Declaring the language2122Same pattern as TypeScript — the import graph declares it. Two23factories: `toKtModelProjectionBase`, `toKtOasOperationProjectionBase`;24snippets extend `KtSnippet`.2526```ts27export const KtModelBase = toKtModelProjectionBase<EnrichmentSchema>({28 id: denoJson.name,29 toEnrichmentSchema,30 toIdentifierName({ refName, enrichments }) {31 return enrichments?.subject?.name ?? capitalize(camelCase(refName))32 },33 // Kotlin's identifier KIND depends on schema shape → may read context34 // (runs only on cache-miss; the NAME stays pure):35 toIdentifierType(refName, context) {36 return { type: toShape(context, peekSchema(context, refName)) }37 },38 toExportPath({ refName, enrichments }) {39 const name = enrichments?.subject?.name ?? capitalize(camelCase(refName))40 return join('@', ...enrichments.generator.basePackage.split('.'), `${name}.generated.kt`)41 }42})43```4445The export path's directory segments ARE the Kotlin package (§4). Make46`basePackage` a **required generator-scope enrichment** with no default;47validate segments with `isKtIdentifierName` + `ktHardKeywords`. Put the48shape dispatch (object+props → `data-class`; string+enums →49`enum-class`; qualifying discriminated union → `sealed-interface`; else50`typealias`) in ONE deterministic function read by both51`toIdentifierType` and the constructor, so kind and value can't disagree.5253## 2. Register shapes — Kotlin differences5455Same three shapes as TS (projection own-file / `registerInto` / snippet56with required `destinationPath`), plus `defineAndRegister` (no cache57check; no `noExport` — visibility is the identifier's fact: pass58`exported: false` to the factory). Compile-time differences: **no59`reExports` field** (Kotlin has none) and **no `type` tag on imports**60(no type-only imports). `custom` renders above the `package` directive.6162## 3. Identifier kinds6364Kotlin output has seven entity kinds (`KtEntityType`): `class`,65`data-class`, `enum-class`, `interface`, `sealed-interface`,66`typealias`, `val` —67factories `createClass`, `createDataClass`, `createEnumClass`,68`createInterface`, `createSealedInterface`, `createTypeAlias`,69`createValue` (only `createValue` takes `typeName`; `exported: false`70renders `private `). Deferred kinds (`object`, `fun`, `var`) make71`toKtEntityType` throw — deliberately loud. Kind does NOT affect import72form. The engine's `type` is an opaque string: `isKtEntityType` narrows73it to the vocabulary above, and `isKtIdentifier` narrows a neutral74`IdentifierBase` back to `KtIdentifier`.7576## 4. Emitted-import rules7778- **Packages from paths**: `@/com/example/api/User.generated.kt` →79 `package com.example.api`. Segments are validated, never sanitized —80 a keyword or invalid segment **throws** (fix the path policy).81- One `import pkg.Name` per symbol (no brace grouping), `as` aliases,82 rendered **sorted** (determinism, not style).83- **Same-package suppression is central**: register imports84 unconditionally; `KtFile` drops same-package ones at render.85- Importing from the default package throws (root-level artifact86 referenced from a packaged one = path-policy bug).8788## 5. Render model: head + value8990Assignment kinds (`typealias`, `val`): `<head> = <value>`. Declaration91kinds: `<head><value>` — the value renders everything after the name:92parameter list (parens included), inline ` : Parent` clauses, ` { … }`93bodies; an empty value yields the bodyless idiom94(`sealed interface Animal`).9596Two things ride on value-carried protocols (the neutral Lang signature97has no slot for them): `KtAnnotated` (`annotations: KtAnnotation[]`,98strict — string look-alikes are silently dropped) and `KtDocumented`99(`description`, guard `isKtDocumented`, rendered as KDoc above the100annotations). **The mirroring gotcha**: the Driver wraps the101PROJECTION as the definition's value, so mirror both onto the projection102— canon is **reference assignment in the constructor**103(`this.annotations = this.value.annotations` — one array, two names;104never copy) — or class-level annotations and KDoc silently vanish.105106## 6. Composition classes (current API)107108- `KtParameterList(parameters)` — parens included; each109 `{ name, type: Stringable, nullable?, defaultValue?, annotations?,110 visibility? }` renders as an indented `val`, annotations one per line.111- `KtPrimaryConstructor({ parameters, modifiers? })` — modifiers force112 the explicit `constructor` keyword.113- `KtFunctionSignature({ name, parameters, returnType?, annotations?,114 body? })` — abstract by default, expression body only.115- `KtAnnotation({ context, name, args?, target?, packageName?,116 destinationPath })` — a **registering leaf**: with `packageName` it117 registers its own import (register unconditionally; suppression118 handles same-package). `args` are pre-quoted (`['"user_id"']`,119 `['Foo::class']`). `target` is the use-site target120 (`KtAnnotationTarget`: `field`/`get`/`set`/…) rendered as121 `@field:JsonAnySetter` — the imported symbol stays the bare `name`.122 Needed on a constructor `val`, which is parameter/property/field/123 getter at once: Jackson's catch-all pair is `@field:JsonAnySetter` +124 `@get:JsonAnyGetter`, and without targets both annotations land on125 the parameter, where Jackson never looks. (Shipped in lang-kotlin126 0.10.0, 2026-08-04 — pre-`target` versions cannot express use-site127 targets at all.)128- `withDescription(value, { description })` — KDoc.129130## 7. Sanitization and @SerialName131132`sanitizePropertyName(name)`: plain → unchanged; hard keyword or invalid133→ **backticked**; JVM-unescapable characters → **throws** ("rename +134@SerialName"). Renames are NOT its job — serialization annotations135handle wire-name mismatches, and the two compose: decide the annotation136by comparing the *unescaped* chosen name with the wire key137(`` `object` `` needs no @SerialName; `user_id`→`userId` does). Only the13828 hard keywords escape; soft/modifier keywords (`value`, `data`,139`sealed`) are legal identifiers. Canonical pairing:140`sanitizePropertyName(camelCase(key))`.141142## 8. Worked example — kotlinx data class (current API, engine-pinned)143144Per-property loop inside the data-class value snippet:145146```ts147const propertyName = sanitizePropertyName(camelCase(key))148const annotations: KtAnnotation[] = []149if (propertyName.replaceAll('`', '') !== key) {150 annotations.push(new KtAnnotation({151 context, destinationPath,152 name: 'SerialName', packageName: 'kotlinx.serialization', args: [`"${key}"`]153 }))154}155parameters.push({156 name: propertyName,157 type: value, // the SNIPPET — never `${value}`158 defaultValue: isRequired ? undefined : 'null',159 annotations160})161// this.parameterList = new KtParameterList(parameters)162// class-level: this.annotations = [new KtAnnotation({ context,163// destinationPath, name: 'Serializable', packageName: 'kotlinx.serialization' })]164// projection mirrors by REFERENCE: this.annotations = this.value.annotations165```166167Renders (verified byte-for-byte through the engine):168169```kotlin170package com.example.api171172import kotlinx.serialization.SerialName173import kotlinx.serialization.Serializable174175@Serializable176data class User(177 @SerialName("user_id")178 val userId: String,179 val name: String,180 val email: String? = null181)182```183184The type expression is the **single owner** of `?`; the parameter layer185only adds `= null`. Passing `` `${value}` `` instead of the snippet186strands its registered imports and synthesized siblings — the file187breaks far from the cause. Serialization flavor is confined to the value188files (data class / enum entries / sealed interface): a Jackson/Moshi189sibling generator swaps annotation construction there only.190191## 8b. Normalized models — KNOWN ENGINE GAP (verified 2026-08-03)192193The head+value model means a Kotlin value renders differently in TYPE194position (`Map<String, Any?>`) and DECLARATION position (a parameter195list). Core's generic `insertNormalizedModel` glues the identifier196head to the value's type-position `toString()` — which for an inline197OBJECT schema renders invalid Kotlin: `data class XMap<String, Any?>`.198The engine gap is real, but the SOLUTION does not wait for it —199every mature Kotlin generator solves inline objects the same way:200201- **Named `$ref` schemas are unaffected** — `insertModel` and the ref202 path work correctly.203- An inline NON-object schema normalizes fine as a `typealias`-shaped204 value.205- **An inline object is SYNTHESIZED as a named sibling declaration**206 and referenced by name — the retired gen-kotlin-kotlinx pattern207 (`KtObjectValue`, skmtc-generators history at `2c24a65`) rebuilt208 WITHOUT its naming-hint threading: the name derives from the209 schema's own `stackTrail` (`toSynthesizedName.ts` in210 gen-kotlin-jackson — anchor on the `components`/`paths` landmark211 frames, never absolute indices; classification is POSITIONAL:212 `properties` consumes the following frame as a literal key, so a213 property named `properties`/`schema`/`items` can never be mistaken214 for trail structure), so every construction path — including peers215 arriving through `insertNormalizedModel` — lands on the same name216 with NO parameter added to the router contract. Names are NOT217 collision-free: claim via the document-wide registry218 (`claimSynthesizedName`, gen-kotlin-jackson `synthesizedNames.ts`)219 BEFORE declaring — it throws per-item when the name collides with a220 component-derived class name (Kotlin's redeclaration scope is the221 PACKAGE, not the file) or with a different position's claim222 (camelCase-convergent keys), and returns reuse for a same-position223 re-walk. On `'declare'`, `defineAndRegister` the sibling and render224 only the NAME. Type position then always holds a name or a map —225 never property structure. This is also how OpenAPI Generator solves it226 (inline schemas hoisted to named components before generation).227 Widening a known shape to `Map<String, Any?>` is capitulation, not228 a solution — it discards the type the schema gave you. Inline229 string enums synthesize the same way (`enum class` sibling).230- Never fabricate a refName or drive the peer's identity statics to231 force a declaration into existence — that is the two-doors rule232 (skmtc-generator §4), and the result couples you to the peer's233 private snippet shape.234235## 8c. Discriminated unions — sealed interfaces (shipped 2026-08-04)236237Kotlin has no union type; a QUALIFYING discriminated union becomes a238`sealed interface` (gen-kotlin-jackson is the worked example; ancestry:239the retired kotlinx machinery at skmtc-generators `2c24a65`, stale call240shapes). Predicate (`shape.ts isSealedUnion`, part of the shape241dispatch): discriminated, ≥2 members, every member a `$ref` to an242object-with-properties, and every member keeps ≥1 parameter AFTER243discriminator omission. Everything else renders the honest wire type244(`JsonNode` for Jackson), never `Any`.245246- **The inversion scan.** OpenAPI points parent → member; Kotlin247 declares member → parent (`data class Dog(...) : Pet`). Memoization248 makes build order arbitrary, so membership must be known BEFORE any249 construction: one document-wide scan over `components.schemas`,250 memoized per document via `WeakMap`, mapping member refName → claims.251 Claims store the parent's real `RefName`; the consumer derives the252 display name via `context.toModelContentSettings` — never a copy of253 the naming policy, never a fabricated refName.254- **Parent side**: an empty-body value (`toString()` returns `''` →255 the bodyless idiom) carrying `@JsonTypeInfo(use = NAME, include =256 PROPERTY, property = "<discriminator>")` + `@JsonSubTypes(Type(value257 = Dog::class, name = "dog"), …)` via the `KtAnnotated` protocol —258 mirror `annotations` AND `description` on the projection by259 reference. Each subtype entry holds the walked member ref SNIPPET,260 so member models build and imports stitch through the normal chain.261 Tags: `discriminator.mapping` key pointing at the member, else the262 member's refName (the OpenAPI default).263- **Member side**: inline ` : Pet` supertype clause rendered by the264 parameter-list value (after the parens), and the discriminator265 property OMITTED — filtered BEFORE the property walk, or its enum266 schema synthesizes a spurious sibling. Same package by the267 export-path policy satisfies Kotlin's sealed same-package rule.268- **Jackson vs kotlinx flavor**: tags are parent-side269 (`@JsonSubTypes`), so members carry no tag annotation and one member270 may hold different tags under different parents (the kotlinx271 one-`@SerialName`-per-class conflict rule does not apply).272- **Runtime gotcha (probed)**: a raw `writeValueAsString(list)` erases273 the element type and silently DROPS the tags; concrete roots,274 `writerFor(type)`, and full-generic types all write them — Spring275 MVC uses the typed path, so real consumers are fine. Test round-trips276 with a typed writer.277- `allOf`-composed members (the spec's canonical idiom: shared fields278 on a base, members compose via `allOf`) qualify WITHOUT special279 handling — core resolves `allOf` at parse time (`mergeIntersection`),280 so the member peeks as a flat object and the base's fields flatten281 into each data class (verified through the pipeline 2026-08-04).282 Flattening is the right Kotlin target: the sealed interface is the283 polymorphism seam, not class inheritance.284- **Inline unions (stage 2, shipped)**: a qualifying union ANYWHERE —285 component property, operation body/response/header/parameter —286 synthesizes its sealed parent under its stackTrail name (combinator287 frames `oneOf`/`anyOf`/`allOf` are structural and elided; a288 `parameters/<index>` position resolves to the parameter NAME via a289 WeakMap document scan — the trail itself cannot carry names, it290 doubles as a JSON Pointer where `parameters` is an array) into the291 MODELS package (`toModelExportPath` — ONE placement policy for EVERY292 synthesized declaration; caller's-file placement breaks293 `'reuse'`-across-files for cross-package peers). The scan deep-walks294 components AND operations AND webhooks (headers and the `content`295 alternative included); synthesized claims carry the union NODE so296 `ensureSealedParent` lets WHOEVER needs the name first declare it via297 the claim registry. Derivability is ONE shared non-throwing probe298 (`toSynthesizedNameOrNull`) across scan/render/members — underivable299 roots degrade consistently to pre-synthesis behavior; the object/enum300 sites deliberately keep the THROWING derivation (no honest fallback301 exists for structure). One member may implement several sealed302 parents (parent-side tags).303- Not yet built: undiscriminated unions (stage 3 —304 enrichment-asserted hints / Jackson `Id.DEDUCTION`) and the INVERTED305 swagger-style pattern (discriminator on the base, no `oneOf`,306 membership implied by `allOf` back-references — no union node exists,307 so no sealed interface).308309## 9. Kotlin pitfalls310311| Symptom | Fix |312|---|---|313| `@Serializable`/KDoc missing | Mirror `annotations`/`description` getters on the projection |314| Annotation silently dropped | Real `KtAnnotation` instances, not strings |315| `segment 'x' is not a valid package name part` | Fix the export-path policy — packages validate, never sanitize |316| Import mid-file / duplicated | `register` / annotation `packageName`, never templates |317| `String??` | Type expression owns the single `?` |318| `Unknown Kotlin entity type` | Use the seven Kotlin factories, not TS kinds |319| Empty `data class` throws | Shape dispatch must route empty objects to `typealias` |320| TDZ crash at module load | Break base↔router↔projection cycles with a leaf module (`peekSchema` pattern) |321| Nondeterministic output | No module state; config via enrichments; memoize document scans in `WeakMap` |322| `data class X` glued to `Map<String, Any?>` | The normalized-insert type/declaration gap — §8b, don't hack around it |323| Union renders `Any`/`JsonNode` where a sealed type was expected | Qualifying predicate failed — check discriminator presence, all-ref members, per-member surviving parameters (§8c) |324| Member missing ` : Parent` / spurious discriminator enum sibling | Membership scan not consulted before construction, or omission applied after the property walk (§8c) |325| Sealed round-trip loses the wire tag at runtime | Jackson root-list type erasure — serialize via a typed writer; generated code is correct (§8c) |326327<!-- api-appendix:begin — GENERATED, do not edit by hand -->328329## Appendix — generated API reference330331The full `deno doc` surface for the packages this skill covers lives332in [`appendix.md`](appendix.md), in this skill's directory —333generated from framework source — signatures and field docs only.334It is **authoritative**: when the prose above does335not carry the exact constructor or field shape you need, Read (or336grep) `appendix.md` instead of diving into package source. Do not337guess signatures. For a symbol not listed there,338`deno doc <file> <Symbol>` against the framework source beats339grepping it.340341<!-- api-appendix:end -->