skmtc-lang-kotlin · v0.11.0 · 2026-08-30 · sha256 e2d546b7afd889f9

skmtc-lang-kotlin v0.11.0A

Immutable. This exact content is served forever at /api/v1/blob/e2d546b7afd889f9.

---
name: skmtc-lang-kotlin
version: 0.11.0
description: >
  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.
metadata:
  internal: true
---

# 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`.

```ts
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 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 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:

```ts
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):

```kotlin
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) |

<!-- api-appendix:begin — GENERATED, do not edit by hand -->

## Appendix — generated API reference

The full `deno doc` surface for the packages this skill covers lives
in [`appendix.md`](appendix.md), in this skill's directory —
generated from framework source at `0f26b1b7`, 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.

<!-- api-appendix:end -->