skmtc-lang-kotlin
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 an
Files2
View on GitHubSKILL.mddeno/docs/skills/skmtc-lang-kotlin/SKILL.md
GitHubThe Kotlin layer (@skmtc/lang-kotlin)
Read skmtc-generator first.
Drift warning. The API of record is the workspace
skmtc/deno/lang-kotlinand its tests. The shippedgen-kotlin-*generators predate the 0.9.11 flattening: they callnew KtAnnotation('Name', [args])positionally and importisKtAnnotated/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 bylang-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 `@/${enrichments.generator.basePackage.replaceAll('.', '/')}/${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.Nameper symbol (no brace grouping),asaliases, rendered sorted (determinism, not style). - Same-package suppression is central: register imports
unconditionally;
KtFiledrops same-package ones at render. - Importing from the default package throws (root-level artifact referenced from a packaged one = path-policy 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 indentedval, annotations one per line.KtPrimaryConstructor({ parameters, modifiers? })— modifiers force the explicitconstructorkeyword.KtFunctionSignature({ name, parameters, returnType?, annotations?, body? })— abstract by default, expression body only.KtAnnotation({ context, name, args?, target?, packageName?, destinationPath })— a registering leaf: withpackageNameit registers its own import (register unconditionally; suppression handles same-package).argsare pre-quoted (['"user_id"'],['Foo::class']).targetis the use-site target (KtAnnotationTarget:field/get/set/…) rendered as@field:JsonAnySetter— the imported symbol stays the barename. Needed on a constructorval, 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-targetversions 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
$refschemas are unaffected —insertModeland 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 at2c24a65) rebuilt WITHOUT its naming-hint threading: the name derives from the schema's ownstackTrail(toSynthesizedName.tsin gen-kotlin-jackson — anchor on thecomponents/pathslandmark frames, never absolute indices; classification is POSITIONAL:propertiesconsumes the following frame as a literal key, so a property namedproperties/schema/itemscan never be mistaken for trail structure), so every construction path — including peers arriving throughinsertNormalizedModel— 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-jacksonsynthesizedNames.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',defineAndRegisterthe 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 toMap<String, Any?>is capitulation, not a solution — it discards the type the schema gave you. Inline string enums synthesize the same way (enum classsibling). - 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 overcomponents.schemas, memoized per document viaWeakMap, mapping member refName → claims. Claims store the parent's realRefName; the consumer derives the display name viacontext.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 theKtAnnotatedprotocol — mirrorannotationsANDdescriptionon 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.mappingkey pointing at the member, else the member's refName (the OpenAPI default). - Member side: inline
: Petsupertype 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 viaallOf) qualify WITHOUT special handling — core resolvesallOfat 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/allOfare structural and elided; aparameters/<index>position resolves to the parameter NAME via a WeakMap document scan — the trail itself cannot carry names, it doubles as a JSON Pointer whereparametersis 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 thecontentalternative included); synthesized claims carry the union NODE soensureSealedParentlets 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, nooneOf, membership implied byallOfback-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.