skmtc-lang-kotlin · v0.10.0 · 2026-08-29 · sha256 6f5ac3d96449d11d
skmtc-lang-kotlin v0.10.0A
Immutable. This exact content is served forever at /api/v1/blob/6f5ac3d96449d11d.
---
name: skmtc-lang-kotlin
version: 0.10.0
description: |
The Kotlin target-language layer for SKMTC generators
(`@skmtc/lang-kotlin`). Covers how a generator declares Kotlin as its
target language (importing `toKtModelProjectionBase` /
`toKtOasOperationProjectionBase` and `KtSnippet` from the lang
package), what the lang package exports (the `kotlin` Lang object,
the register family, `KtFile` / `KtImport` / `KtDefinition`), the
head+value rendering model, entity kinds and identifier factories,
the import model of emitted Kotlin (packages from paths, symbol-level
imports, same-package suppression, no type-only imports), the value
composition classes (`KtParameterList`, `KtPrimaryConstructor`,
`KtFunctionSignature`, `KtAnnotation`), the `SchemaToValueFn` router
contract and where serialization annotations and default values are
decided (inside the router's per-type snippets, exposed as value
fields), and naming/sanitization (`sanitizePropertyName`,
`toPackageName`, hard keywords like `object`).
Use this skill alongside `skmtc-generator` whenever a generator emits
Kotlin — and specifically when the user asks about "lang-kotlin",
"KtSnippet", "data class generation", "sealed interface", "package
directive", "backticks", "@SerialName"/"@JsonProperty" wiring,
"KtAnnotation", or anything about the *shape of the emitted Kotlin*
rather than engine behavior. Engine rules (Projections, Snippets,
cross-generator coordination, variants) live in `skmtc-generator`.
allowed-tools:
- Bash
- Read
- Glob
- Grep
- Write
- Edit
metadata:
internal: true
---
# SKMTC Kotlin language layer
This skill covers the **target-language** side of generator authoring:
what the emitted Kotlin looks like and which package owns each piece.
The boundary rule, worth internalizing first:
> **The authoring language is always TypeScript/Deno; only the target
> language varies.** Rules about how generator *source* is written
> (`as` casts, `switch`+`never`, Valibot enrichments) live in
> `skmtc-generator`. This skill covers how the generator's *output* is
> shaped — files, packages, imports, declarations, naming — for
> generators whose target is Kotlin.
This skill follows the `skmtc-lang-<X>` template established by
`skmtc-lang-typescript`: the same seven section headings, with Kotlin's
answers. Read §2 first if you read only one section — the **head +
value rendering model** is the intuition every other rule falls out of.
## 1. Package surface
`@skmtc/lang-kotlin` exports:
| Export | What it is |
|---|---|
| `kotlin` | The `Lang` object. Three neutral factories the engine's **Drivers** call, reading it ephemerally off the projection class's inherited static (`projection.lang`): `createFile`, `toDefinition`, `toImport` (+ the identifier-assembly seam `toIdentifier`). Generators never call it |
| `KtSnippet` | The snippet base — where Kotlin enters the DSL class hierarchy. Carries the static `lang`; its `register` / `defineAndRegister` methods are typed by the concise vocabulary. Registering snippets are **keyless** (`generatorKey` is optional attribution input) and always pass an explicit `destinationPath` |
| `toKtModelProjectionBase` / `toKtOasOperationProjectionBase` | The projection-base veneers over core's factories — pre-bind `KtSnippet` as the factory's positional first argument and add own-file `register(args)` + explicit cross-file `registerInto(destinationPath, args)`. The config is core's `ModelProjectionBaseConfig<E, KtIdentifierType>` (etc.), so `toIdentifierType`'s return is compile-time bound to Kotlin's entity kinds |
| `register` / `defineAndRegister` | The register **functions** — convert the concise form, create the destination `KtFile` on first write, hand pure data to the neutral `context.register`. Transforms (closures with no class) import `defineAndRegister` directly. `register` throws on a cross-language file collision |
| `KtRegisterArgs` / `KtDefineAndRegisterArgs` | The concise register vocabulary: `imports` / `definitions` / `custom` (leading file content — see §3). Deliberately **no `reExports` field**: Kotlin has no re-exports, so a generator registering one is a compile-time error, not a runtime no-op |
| `KtFile` | `CodeFileBase` subclass — a Kotlin output file. Derives its `package` directive from its own export path (§5), sorts imports alphabetically, suppresses same-package imports, renders the `custom` slot above the `package` line |
| `KtImport` / `KtImportNameArg` / `KtImportSpecifier` | `ImportBase` subclass — symbol-level import statements with `as` aliases; no brace grouping, no type-only form (§3) |
| `KtDefinition` | `DefinitionBase` subclass — renders `${head}${value}` (declaration kinds) or `${head} = ${value}` (assignment kinds), plus the value-carried `KtAnnotated` / `KtDocumented` protocols above the line (§2, §4) |
| `KtIdentifier` / `isKtIdentifier` / `KtIdentifierType` / `KtIdentifierArgs` | Kotlin's concrete `IdentifierBase`: carries the typed `type: KtEntityType` and renders its own **declaration head** — `[private ]<keyword> <name>[: <typeName>]` |
| `createClass` / `createDataClass` / `createEnumClass` / `createInterface` / `createSealedInterface` / `createTypeAlias` / `createValue` | The identifier factories — one per entity kind (§2) |
| `KtEntityType` / `isKtEntityType` / `toKtEntityType` | The entity-kind vocabulary, its guard, and the throw-narrowing of the engine's opaque `type` string |
| `KtParameterList` | Primary-constructor parameter list, **parentheses included** (§4) |
| `KtPrimaryConstructor` | Constructor modifiers + the explicit `constructor` keyword rule (§4) |
| `KtFunctionSignature` / `KtFunctionParameter` | Method signatures for interface/class bodies — annotations, KDoc, expression bodies, parameter defaults (§4) |
| `KtAnnotation` / `KtAnnotations` / `toKtAnnotations` / `KtAnnotated` | Annotation rendering + the self-registering import (§3, §4) |
| `KtDocumented` / `isKtDocumented` | The value-carried KDoc protocol (§4) |
| `withDescription` | Wraps a rendered declaration in a KDoc comment block |
| `sanitizePropertyName` | Kotlin/JVM property-name sanitization — backtick escaping, hard keywords (§5) |
| `toPackageName` | `@/`-export-path → dotted package, with segment validation (§5) |
| `ktHardKeywords` / `isKtIdentifierName` | The validation primitives behind the two above |
| `langId` | `'kotlin'` |
| `fileExtensions` | `['.kt']` |
### Wiring — the import graph declares the language
```ts fragment
// gen-x/src/base.ts — the language enters HERE, through the import
import { toKtModelProjectionBase } from '@skmtc/lang-kotlin'
import { emptyEnrichmentSchema } from '@skmtc/core'
import denoJson from '../deno.json' with { type: 'json' }
export const KtModelBase = toKtModelProjectionBase({
id: denoJson.name,
// Pure — the cache-key name. For DTO generators the refName IS the class name.
toIdentifierName: ({ refName }) => refName,
// Context-aware — the declaration kind, derived from the schema on cache-miss.
// Return type is KtIdentifierType: `type` is compile-checked against §2's vocabulary.
toIdentifierType: (refName, context) => {
const schema = context.resolveSchemaRefOnce(refName, denoJson.name)
return { type: schema.type === 'object' ? 'data-class' : 'typealias' }
},
// The export path doubles as the package — `@/models/…` → `package models` (§5).
toExportPath: ({ refName }) => `@/models/${refName}.generated.kt`,
toEnrichmentSchema: () => emptyEnrichmentSchema
})
```
```ts fragment
// gen-x/src/KtModel.ts — the per-schema Projection
import type { EmptyEnrichments, ModelProjectionArgs, Stringable } from '@skmtc/core'
import { KtAnnotation } from '@skmtc/lang-kotlin'
import { KtModelBase } from './base.ts'
import { toKtValue } from './Kt.ts'
export class KtModel extends KtModelBase {
// Class-level annotations — the KtAnnotated protocol (§4). The Driver
// wraps THIS instance in the Definition, so the projection IS the
// definition's value and the protocol field lives directly on it:
// KtDefinition reads it as `this.value.annotations`.
annotations: KtAnnotation[]
value: Stringable
// The enrichment generic must match the base: `toEnrichmentSchema:
// () => emptyEnrichmentSchema` makes the base expect
// ModelProjectionArgs<EmptyEnrichments>; the bare default fails
// `deno check` — which bundle/generate will NOT catch (esbuild does
// not typecheck).
constructor({ context, settings, refName }: ModelProjectionArgs<EmptyEnrichments>) {
super({ context, settings, refName })
const schema = context.resolveSchemaRefOnce(refName, KtModel.id).resolve()
// ONE router call — the generator's SchemaToValueFn owns every
// schema.type decision (skmtc-generator axiom 1); 'object' maps to
// the data-class value snippet inside the router, not here.
this.value = toKtValue({
context,
schema,
destinationPath: settings.exportPath, // snippets/leaves always get the parent's file
required: true
})
// Reference-sharing: point the protocol field at the routed value's
// OWN array — one array, two names; whatever a router case put there
// is already here. Push projection-level policy into it; NEVER
// reassign it afterward (a later `this.annotations = []` would
// silently split the two names onto different arrays).
this.annotations = this.value.annotations
this.annotations.push(
new KtAnnotation({
context,
name: 'JsonIgnoreProperties',
args: ['ignoreUnknown = true'],
packageName: 'com.fasterxml.jackson.annotation',
destinationPath: settings.exportPath
})
)
}
toString(): string {
return `${this.value}`
}
}
```
```ts fragment
// gen-x/src/mod.ts — the entry is pure pipeline config; no language anywhere
import { toModelEntry } from '@skmtc/core'
import denoJson from '../deno.json' with { type: 'json' }
import { KtModel } from './KtModel.ts'
export const ktModelEntry = toModelEntry({
id: denoJson.name,
transform({ context, refName }) {
context.insertModel(KtModel, refName)
},
toEnrichmentSchema: () => emptyEnrichmentSchema
})
```
A generator declares its language **only** by importing its
projection-base veneer (and, for registering snippets, `KtSnippet`)
from this package. The language rides the class hierarchy as the
static `lang` on `KtSnippet`; the engine's Drivers read it ephemerally
off the projection class when they need to create a file or build a
Definition. Nothing else changes versus a TypeScript generator: the
same entry factories, the same engine calls — `insertModel(Peer,
refName)` for a named `$ref`, `insertNormalizedModel(Peer, { schema,
fallbackName })` for a schema that may be inline or a ref,
`findDefinition` to read the cache — the same `ContentSettings`. All
engine-side, all in `skmtc-generator`; do not re-derive them from core
source.
Generators normally never construct `KtFile` / `KtDefinition` /
`KtImport` directly — this package's register functions and the
engine's Drivers build them. If you find yourself writing
`new KtImport(...)` in a generator, you almost certainly wanted
`this.register({ imports })`.
The package dependency (both required, **same `lang-kotlin` pin in
every package of the stack** — see §6):
```jsonc
// gen-x/deno.json#imports
{
"@skmtc/core": "jsr:@skmtc/core@<pin>",
"@skmtc/lang-kotlin": "jsr:@skmtc/lang-kotlin@<pin>"
}
```
The same-pin rule covers **core too**: pin `@skmtc/core` to the exact
version the lang-kotlin package (vendored or JSR) pins — read it from
that package's `deno.json` rather than taking a newer one. Two core
copies in one build break cross-copy `instanceof` exactly like two
lang copies (§6).
### The router contract — `SchemaToValueFn`, no core dive needed
The `toKtValue` router the scaffold above calls is typed by core's
`SchemaToValueFn`. Its whole surface:
```ts fragment
type SchemaType = OasSchema | OasRef<'schema'> | OasVoid | CustomValue
type SchemaToValueFn = <S extends SchemaType>(
args: TypeSystemArgs<S>
) => TypeSystemOutput<S['type']>
type TypeSystemArgs<S extends SchemaType> = {
context: GenerateContextType
destinationPath: string // the parent's file — threaded into every leaf
schema: S // the typed variant after narrowing
rootRef?: RefName // originating ref name, when routing a named schema
required: boolean | undefined
}
```
The contract is **structural**: each per-type snippet class carries the
fields of its output type alongside its own state — nothing extends a
core class for this. The `skmtc create … --lang kotlin` skeleton's
`DataClassValue` is the worked case: `type = 'object' as const`,
`recordProperties: null`, `objectProperties`, `modifiers`. A snippet
for another schema type carries its own output type's fields the same
way (`'string'` → `type`, `format`, `enums`, `modifiers`).
The complete output-shape crib — **exact**, one line per contract
type; a snippet carries its case's fields verbatim (each shape also
takes an optional `generatorKey`; `enums` keeps its full Nullable
union — §4):
```ts fragment
type TypeSystemString = { type: 'string'; format: string | undefined; enums: string[] | (string | null)[] | undefined; modifiers: Modifiers }
type TypeSystemInteger = { type: 'integer'; modifiers: Modifiers }
type TypeSystemNumber = { type: 'number'; modifiers: Modifiers }
type TypeSystemBoolean = { type: 'boolean'; modifiers: Modifiers }
type TypeSystemArray = { type: 'array'; items: TypeSystemValue; modifiers: Modifiers }
type TypeSystemRef = { type: 'ref'; name: string; modifiers: Modifiers }
type TypeSystemObject = { type: 'object'; recordProperties: TypeSystemRecord | null; objectProperties: TypeSystemObjectProperties | null; modifiers: Modifiers }
type TypeSystemRecord = { value: TypeSystemValue | 'true' }
type TypeSystemObjectProperties = { properties: Record<string, TypeSystemValue> }
type TypeSystemUnion = { type: 'union'; members: TypeSystemValue[]; discriminator: string | undefined; modifiers: Modifiers }
type TypeSystemUnknown = { type: 'unknown' }
type TypeSystemCustom = { type: 'custom'; value: Stringable }
```
Two more facts that save a dive into `core/types/TypeSystem.ts`:
- **`Modifiers` is deliberately thin** — `{ required?, description?,
nullable? }` and nothing else. Wire facts (`readOnly` / `writeOnly`,
`format`) are NOT threaded through modifiers; the per-type snippet
holds its typed schema variant and reads them directly (§4,
"annotations and defaults are decided inside the per-type snippet").
- **Keep the router's plain signature** (§4: union in, union out,
intersected with `KtValueFields`) — the generic
`TypeSystemOutput<S['type']>` tightening is documented there as a
rare exception, not the default.
The output types carry `.type` discriminators, but consumers never
need them (§4): a renderer reads fields off routed values —
`annotations`, `defaultValue` — instead of narrowing.
## 2. Entity kinds & identifiers
Kotlin output has seven entity kinds (`KtEntityType`), created via the
identifier factory functions exported by this package. **The identifier
renders its own declaration head; the value renders everything after
it.** `KtDefinition` composes the two without inspecting either:
```text
declaration kinds: ${head}${value} class | data-class | enum-class | interface | sealed-interface
assignment kinds: ${head} = ${value} typealias | val
```
| Factory | Head rendered | The value then renders |
|---|---|---|
| `createClass('UsersService')` | `class UsersService` | `${primaryConstructor}${supertypeClause}${body}` |
| `createDataClass('User')` | `data class User` | `${parameterList}${supertypeClause}` |
| `createEnumClass('Status')` | `enum class Status` | ` {\n ACTIVE,\n …\n}` |
| `createInterface('UsersApi')` | `interface UsersApi` | ` {\n${signatures}\n}` |
| `createSealedInterface('Animal')` | `sealed interface Animal` | usually nothing — the bodyless idiom |
| `createTypeAlias('UserList')` | `typealias UserList` | the right-hand side (`List<User>`) |
| `createValue('timeout', { typeName: 'Long' })` | `val timeout: Long` | the right-hand side expression |
The consequences, each a rule you'd otherwise rediscover:
- **A value's `toString()` starts where the head ends.** For a data
class that means the parameter list (parens included, §4); for an
enum/interface it means the braced body *including* the leading
` {`; for a sealed interface it is typically the empty string —
an empty render IS how `sealed interface Animal` gets its bodyless
form. Never render the keyword, the name, or `data class` inside a
value.
- **Visibility is the identifier's fact.** Kotlin defaults to public,
so `exported: true` (the default) renders nothing and
`createDataClass('User', { exported: false })` renders
`private data class User`. There is no `noExport` on this package's
definition/register args — the neutral Driver-level `noExport` flag
is folded into a restricted identifier copy at the `Lang.toDefinition`
boundary, and in-generator code passes `exported: false` to the
factory instead.
- **The `typeName` slot is the `val x: T` annotation** — only
`createValue` takes it. It is part of the head, not the value.
- `isKtIdentifier` narrows a neutral `IdentifierBase` back to
`KtIdentifier`; `toKtEntityType` / `isKtEntityType` narrow the
engine's opaque `type` string. Generators rarely call either — the
veneer's `ModelProjectionBaseConfig<E, KtIdentifierType>` makes
`toIdentifierType`'s return compile-checked, and a foreign
identifier fails loudly at the Generate boundary.
- Unlike TypeScript, the entity kind does **not** drive import form —
every Kotlin import is `import pkg.Name`. It drives only the
declaration shell. Kinds not yet in the vocabulary (`object`, `fun`,
`var`) arrive with the milestones that need them; `toKtEntityType`
throwing on them is the desired behavior until then.
## 3. The import model of emitted Kotlin
Generators register imports in the concise form (`KtImportNameArg`);
the register function converts them to `KtImport`s at the boundary:
```ts fragment
this.register({
imports: {
// dotted package — external libraries
'com.fasterxml.jackson.annotation': ['JsonProperty', 'JsonSubTypes'],
// @/-export path — project files (rarely hand-written: the Driver
// registers peer imports for you on insertModel/insertOperation)
'@/models/Role.generated.kt': ['Role'],
// symbol-level alias — Kotlin's `as`
'kotlinx.serialization.json': [{ name: 'Json', alias: 'KJson' }]
}
})
```
Rendering rules `KtFile` / `KtImport` apply (authors never hand-write
these):
- **One statement per symbol** (`import kotlinx.serialization.Serializable`)
— Kotlin has no brace grouping. Imports render **sorted
alphabetically**, so registration order cannot leak into output.
- **No type-only imports.** `KtImportNameArg` has no `type` tag —
the TS1484 discipline from lang-typescript has no Kotlin
counterpart. If you find yourself tagging imports, you're writing a
TypeScript instinct into a Kotlin generator.
- **Path-form modules resolve to packages at render.**
`'@/models/Role.generated.kt'` → `import models.Role` via
`toPackageName` (§5). The Driver passes exactly this form for
cross-file peer imports.
- **Same-package imports are suppressed centrally.** A symbol in the
destination file's own package needs no import in Kotlin, and
`KtFile` drops it at render. Over-registering is therefore harmless
— callers never need a same-package check. The single-file
consequence: in a generator that registers every model into ONE
file (the DTO-file idiom), peer references need **no import wiring
at all** — get the peer's name through the `insertModel` handle
(`.toName()`, per `skmtc-generator` §4) and interpolate it; there
is no circular-import hazard to design around, since Kotlin
same-file declarations reference each other freely. Concretely, the
router's whole `ref` case is those two calls: a `RefValue` snippet
whose constructor does `context.insertModel(KtModel,
ref.toRefName())` and whose `toString()` returns the stored
`.toName()` — no register, nothing else.
- **Importing from the default package throws.** Kotlin cannot import
a root-level (package-less) symbol from a packaged file; hitting
this means a generator's path policy put an artifact at `@/<Name>.kt`
that packaged files reference — give the artifact a package path.
- **Re-exports don't exist.** No barrels, no `reExports` field. The
absence is compile-time by design.
### Annotation imports — the self-registering leaf
The dominant import in generated Kotlin is an annotation class.
`KtAnnotation` makes the annotation and its import **one statement**
(the `TsHeritage` registering-leaf pattern), so they cannot drift
apart:
```ts fragment
new KtAnnotation({
context,
name: 'JsonProperty',
args: ['"user_id"'],
packageName: 'com.fasterxml.jackson.annotation', // self-registers the import
destinationPath // always explicit — the parent knows its file
})
// renders: @JsonProperty("user_id")
// registers: import com.fasterxml.jackson.annotation.JsonProperty
```
Omit `packageName` for default-scope annotations (`@Deprecated`,
`@Suppress` — `kotlin.*` needs no import): the annotation then only
renders. Never pair a `KtAnnotation` with a separate manual
`register({ imports })` for the same class (§6).
Multi-element `args` render comma-joined on **one line**
(`args.join(', ')`). For a multi-line annotation body — the
`@JsonSubTypes` idiom with one `JsonSubTypes.Type(…)` entry per
line — pass a SINGLE arg carrying the whole body: a `Stringable`
snippet whose `toString()` composes the lines (keeping composition
inside `toString()`), or a pre-formatted string.
The self-registration covers the annotation's **own** class only.
When the annotation's *arguments* name a second symbol —
`@JsonSerialize(using = MoneyStringSerializer::class)`,
`@field:JsonDeserialize(using = MoneyStringDeserializer::class)` —
that symbol needs its own `register({ imports })` alongside the
annotation (the args are opaque `Stringable`s; nothing parses class
references out of them). One annotation, two imports. Exception:
when the referenced class lands in the destination file's own
package, same-package suppression (§3) makes the extra register
unnecessary — though registering it anyway is harmless.
### Leading file content — the `custom` slot
`KtRegisterArgs.custom` sets the destination file's neutral `custom`
slot (`FileBase.custom`), rendered **above the `package` directive**
(only comments may precede `package` in Kotlin) — e.g. a
generated-file attribution banner. Same placement and semantics as
`TsFile`: last non-`undefined` write wins.
## 4. Syntax helpers
The composable value classes a declaration-kind value interpolates.
All return `Stringable`-compatible values that compose in template
literals; **plain Kotlin syntax carries no grammar rule worth a
class**, so supertype clauses (` : Animal`) and braced bodies
(` {\n…\n}`) are written inline in the value's `toString()`.
| Helper | Renders | The grammar rule it owns |
|---|---|---|
| `KtParameterList` | `(\n @Anno\n val id: String,\n val email: String? = null\n)` | Parentheses included; each parameter a `val` property with annotations one per line above it, `private/protected/internal` visibility, `?` nullability, ` = default` |
| `KtPrimaryConstructor` | `(…)` or ` @Anno private constructor(…)` | Modifiers force Kotlin's explicit `constructor` keyword; without modifiers it renders just the parameter list |
| `KtFunctionSignature` | ` @GetMapping("/users/{id}")\n fun getUsersId(@PathVariable("id") id: String): User` | Method signatures inside interface/class bodies: per-signature KDoc + annotations, abstract by default, expression body via `body` (` = …` — block bodies deliberately unsupported), implicit `Unit` when `returnType` omitted |
| `KtFunctionParameter` | `@RequestParam("verbose") verbose: Boolean? = null` | One signature parameter — annotations, nullability, defaults |
| `KtAnnotations` (via `toKtAnnotations(value)`) | One annotation per line + trailing newline; empty renders `''` | The class-level annotation block above a declaration |
| `withDescription(value, { description })` | `/** … */\n${value}` | KDoc — Kotlin's block-comment syntax is JSDoc-identical; multi-line descriptions get ` * ` margins |
The exact constructor shapes — **these are complete**, and the
**generated API appendix** (`appendix.md`, this skill's directory)
carries the full `deno doc` surface of the whole package (every
export, exact argument shapes, generated from source — it cannot
drift). Between this section and that file there is nothing left to
learn from pre-reading the package source; verify with `deno check`
after scaffolding instead:
```ts fragment
// KtParameterList — new KtParameterList(parameters: KtParameterArgs[])
type KtParameterArgs = {
name: string // FINAL name — already sanitized, may be backticked
type: Stringable
nullable?: boolean // renders `Type?`
defaultValue?: Stringable // renders ` = …` (e.g. 'null')
annotations?: KtAnnotation[] // inline, before `val`
visibility?: 'private' | 'protected' | 'internal' // absent = public
}
// KtAnnotation — new KtAnnotation(args: KtAnnotationArgs)
type KtAnnotationArgs = {
context: GenerateContextType
name: string
args?: Stringable[] // pre-quoted, rendered inside `(…)`; omitted → bare @Name
packageName?: string // self-registers the import; omit for kotlin.* scope
destinationPath: string // always explicit — the parent knows its file
}
// KtFunctionSignature — new KtFunctionSignature(args)
type KtFunctionSignatureArgs = {
name: string
parameters: KtFunctionParameterArgs[] // { name, type, nullable?, defaultValue?, annotations? }
returnType?: Stringable // omitted → implicit Unit
annotations?: KtAnnotation[]
description?: string // KDoc above the annotations
body?: Stringable // expression body ` = …`; absent → abstract form
}
// KtPrimaryConstructor — new KtPrimaryConstructor(args)
type KtPrimaryConstructorArgs = {
parameters: Stringable // typically a KtParameterList (owns its parens)
modifiers?: Stringable[] // e.g. annotations / 'private' — forces `constructor` keyword
}
// Identifier factories — createDataClass(name, { exported?: boolean })
// (all kinds; only createValue adds { typeName?: string })
```
```text
data class value: `${parameterList}${supertypes.length ? ` : ${supertypes.join(', ')}` : ''}`
class value: `${primaryConstructor} : ${supertype} {\n${body}\n}`
enum class value: ` {\n ${members.join(',\n ')}\n}`
interface value: ` {\n${signatures.join('\n\n')}\n}`
sealed interface: `` ← the bodyless idiom
typealias / val: the right-hand-side expression
```
### Exact render shapes — verbatim
Generated by constructing each class and printing `toString()` —
match output against these instead of reading the rendering source
(`«»` marks the exact boundaries; every newline and space is real):
```text
createDataClass('User') «data class User»
createDataClass('User', { exported: false }) «private data class User»
createValue('timeout', { typeName: 'Long' }) «val timeout: Long»
createSealedInterface('Animal') «sealed interface Animal»
KtAnnotation { name: 'JsonProperty', args: ['"user_id"'] }
«@JsonProperty("user_id")»
KtAnnotation { name: 'JvmInline' } «@JvmInline»
toKtAnnotations(value) — one per line + TRAILING newline (so
`${annotations}${head}` needs no separator); empty renders «»:
«@JsonProperty("user_id")
@Deprecated("use v2")
»
KtParameterList — parens included, one param per line at 4-space
indent, annotations one per line ABOVE their param, inline
visibility; an EMPTY list renders parens around a blank line:
«(
@JsonProperty("user_id")
val userId: String,
private val name: String,
val email: String? = null
)» empty: «(
)»
KtPrimaryConstructor — without modifiers, identical to its parameter
list; with modifiers, a LEADING space then the explicit keyword:
« private constructor(…)»
KtFunctionSignature — indented 4 spaces (it lives inside a
declaration body); single-line KDoc stays inline; parameters on one
line; expression body appends ` = …`:
« /** Fetch one user. */
@GetMapping("/users/{id}")
fun getUsersId(id: String, verbose: Boolean? = null): User»
« fun toId(raw: String): UserId = UserId(raw)»
KtDefinition (declaration kind) — KDoc, then annotations, then
`${head}${value}` with no space before the value's parens:
«/** A user of the system. */
@JsonSerialize(…)
data class User(
…
)»
KtDefinition (assignment kind) «typealias UserList = List<User>»
withDescription(value, { description }) — MULTI-line descriptions get
a block with ` * ` margins (single-line stays inline as above):
«/**
* Line one.
* Line two.
*/
data class X(…)»
```
### The `oneOf` → sealed interface recipe
The Kotlin idiom for a discriminated `oneOf` (`Animal` = `Dog | Cat`,
discriminated by `petType`) is a sealed parent plus supertyped
members. The pattern: **the union assigns membership to its members**
— a member schema does not know it is in a union and behaves as if it
is not.
- **Members** carry two fields the parent writes to, both reachable
through `inserted.definition.value` (the Driver wraps the projection
instance itself in the Definition, so that IS the member
projection): `supertypes` — declared on the projection, rendered by
its own `toString()` — and the routed value's `parameters` array,
reached as `.value.parameters` (it lives on the object value that
renders it; no projection property, no copy). The router keeps the
**bare `SchemaToValueFn` type** — nothing extra is threaded in;
facts flow *out* of the router on the values it returns:
```ts fragment
// Member side — the §1 projection, grown one seam. A schema that is
// in no union renders exactly as before (supertypes empty).
export class KtModel extends KtModelBase {
supertypes: Stringable[] = []
annotations: KtAnnotation[] // the KtAnnotated protocol slot (§4)
value: ReturnType<typeof toKtValue> // NOT Stringable — so .value reads type-check
constructor({ context, settings, refName }: ModelProjectionArgs<EmptyEnrichments>) {
super({ context, settings, refName })
const schema = context.resolveSchemaRefOnce(refName, KtModel.id).resolve()
this.value = toKtValue({
context,
schema,
destinationPath: settings.exportPath,
required: true
})
// Reference-sharing: KtDefinition reads annotations off THIS
// object, so the protocol slot must exist here — point it at the
// routed value's own array (one array, two names; whatever a
// router case put there is already here). Never reassign either
// name afterward — push, don't replace.
this.annotations = this.value.annotations
}
toString(): string {
return `${this.value}${this.supertypes.length ? ` : ${this.supertypes.join(', ')}` : ''}`
}
}
```
The discriminator tag property is **removed by the parent, during
generate** — not filtered at render: `insertModel` guarantees the
member's constructor has run (existence), so the object value and
its `parameters` array always exist by the time a union case
touches them, whichever visit order; the `KtParameterList` built in
the value's constructor holds the same array instance, so the
removal is visible at render with zero render-time work. The member
never chooses between tags (a member of two unions gets two
removals); which tag appears on the wire is decided at
serialization time by the sealed parent the value is viewed through
(each parent renders its own `@JsonTypeInfo(property = …)`), never
by the member.
- **Parent**: `createSealedInterface(refName)` with a value whose
`toString()` renders `''` — the bodyless idiom gives
`sealed interface Animal`. The router case stays ONE line — `case
'union': return new UnionValue({ context, unionSchema: schema,
destinationPath, rootRef, modifiers: { required } })` — like every
other case: the router routes and constructs, it never does the
work. The work all happens in **`UnionValue`'s constructor**: it
receives the union facts (discriminator name, member list) with the
typed schema, so it is where the parent's class annotations are
decided — pushed into its own `annotations` array; the parent
projection's alias line (previous fragment) makes that same array
its `KtAnnotated` protocol slot. No annotation helper elsewhere, no
`.type` guard anywhere. Membership assignment in a constructor is
exactly axiom 2 — declaration at construction. Member assignment
uses `insertModel` — it returns the one memoized handle however
many producers ask, and `.definition.value` is the member
projection instance — typed as such by `insertModel`'s generics, so
the writes below need no cast:
```ts fragment
// UnionValue, in full: the router case constructs it; the
// constructor owns parent annotations + membership; toString
// renders the bodyless idiom.
export class UnionValue extends KtSnippet {
type = 'union' as const
members: (TypeSystemValue & KtValueFields)[]
discriminator: string | undefined
modifiers: Modifiers
annotations: KtAnnotation[] = []
constructor({ context, unionSchema, destinationPath, rootRef, modifiers }: Args) {
super({ context })
this.modifiers = modifiers
this.discriminator = unionSchema.discriminator?.propertyName
// Members route back through the router — the contract fields
// come for free, and insertModel below is idempotent whether
// reached from here or from the membership loop.
this.members = unionSchema.members.map(member =>
toKtValue({ context, schema: member, destinationPath, required: true })
)
const tag = this.discriminator
if (tag) {
// Wire tag per member: the discriminator mapping keys are the
// wire values, the ref suffix identifies which member each
// maps to; a member absent from the mapping defaults to its
// refName.
const mapping = unionSchema.discriminator?.mapping ?? {}
const entries = unionSchema.members.flatMap(member => {
if (!member.isRef()) return []
const refName = member.toRefName()
const wireTag =
Object.entries(mapping).find(([, ref]) => ref.endsWith(`/${refName}`))?.[0] ?? refName
return [{ className: refName, wireTag }]
})
this.annotations.push(
new KtAnnotation({
context,
destinationPath,
name: 'JsonTypeInfo',
args: [`use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "${tag}"`],
packageName: 'com.fasterxml.jackson.annotation'
}),
new KtAnnotation({
context,
destinationPath,
name: 'JsonSubTypes',
// ONE arg carrying the whole multi-line body (§3) — a tiny
// dedicated snippet, so the composition lives in ITS
// toString(), not built ad hoc here.
args: [new SubTypeEntries({ context, entries })],
packageName: 'com.fasterxml.jackson.annotation'
})
)
}
unionSchema.members.forEach(member => {
if (!member.isRef()) return
const inserted = context.insertModel(KtModel, member.toRefName())
if (rootRef) inserted.definition.value.supertypes.push(rootRef)
if (tag) {
// Remove the tag parameter — Jackson owns it via the parent's
// @JsonTypeInfo. Generate-time mutation of the object value's
// entries array; its KtParameterList sees the settled result.
const parameters = inserted.definition.value.value.parameters ?? []
const at = parameters.findIndex(parameter => parameter.wireName === tag)
if (at >= 0) parameters.splice(at, 1)
}
})
}
override toString(): string {
return '' // the bodyless idiom — the head IS the declaration
}
}
```
```ts fragment
// SubTypeEntries.ts — the multi-line @JsonSubTypes body as its own
// tiny snippet. This is the canonical shape for ANY multi-line,
// multi-symbol annotation argument: facts in the constructor,
// composition in toString().
export class SubTypeEntries extends KtSnippet {
entries: { className: string; wireTag: string }[]
constructor({ context, entries }: SubTypeEntriesArgs) {
super({ context })
this.entries = entries
}
override toString(): string {
return this.entries
.map(({ className, wireTag }) => `JsonSubTypes.Type(value = ${className}::class, name = "${wireTag}")`)
.join(',\\n ')
}
}
```
A note on the structural eval's composition metric: constructor-built
annotation args (the `KtAnnotation` args above, an enum constant's
`@JsonProperty("${wireValue}")`) count toward
composition-outside-`toString()` — that is expected and fine.
Annotations are BUILT at construction by design; the metric's 50%
warning threshold accounts for this, so do not restructure working
code just to move that number.
- **Order cannot matter**: inserts are idempotent and memoized, so
member-first and union-first visits converge on one instance, and
generate completes before render — pinned by core's
`GenerateContext.insert-mutation.test.ts`. Assign during generate
only; `toString()` stays a pure read of the seams. Multi-union
membership composes (` : A, B`) for free.
- This is generator-owned state, NOT a lang protocol — the old
`KtSupertyped` render protocol stays gone. The
`skmtc create … --lang kotlin` scaffold is a deliberate skeleton
and does NOT ship this pattern — this section is the canonical
recipe; implement it in your generator.
### The `enum class` recipe — head and body from two seams
**`enums` is not a string fact.** Every schema variant except `union`
and `unknown` carries it, each over its own element type — integer and
number enums (`enums: number[] | (number | null)[]`), boolean, array,
object. This recipe covers the string case because that is the one
that maps cleanly onto a Kotlin `enum class` with wire values; the
others need a policy decision you make deliberately (an integer enum
can be an `enum class` whose constants carry an `Int`, or just `Int`
with the constraint dropped). Decide it in the router case that owns
that type — a `number` snippet that silently ignores `enums` has made
the decision by omission, and the schema's constraint vanishes with no
trace.
A string-with-`enums` schema becomes `enum class PetType { … }`, and
the declaration is split across the two places §2 defines: the
**identifier** renders the head (`enum class PetType`), so
`toIdentifierType` must return `'enum-class'` — a mapping-metadata
policy, where inspecting the schema is sanctioned:
```ts fragment
// base.ts — declaration KIND is metadata (toIdentifierType may
// inspect the schema; it decides what a node is CALLED, never what
// renders it).
toIdentifierType: (refName, context): KtIdentifierType => {
const schema = context.resolveSchemaRefOnce(refName, denoJson.name).resolve()
if (schema.type === 'object') return { type: 'data-class' }
if (schema.type === 'union') return { type: 'sealed-interface' }
if (schema.type === 'string' && schema.enums) return { type: 'enum-class' }
return { type: 'typealias' }
}
```
The **value** renders the braced body — the router's `string` case
forks on `enums` presence (a schema fact; the same within-case
license as the `format` branch and the object case's Map fork):
```ts fragment
case 'string':
return schema.enums
? new EnumClassValue({ context, stringSchema: schema, destinationPath, modifiers })
: new StringValue({ context, stringSchema: schema, destinationPath, modifiers })
```
```ts fragment
// Renders ONLY the body — ` {\n…\n}` including the leading space and
// braces (§2: the value owns its shell; the head comes from the
// identifier). Everything is decided and built in the constructor:
// wire value → UPPER_SNAKE constant name, the @JsonProperty rename
// each constant keeps, and which constant gets @JsonEnumDefaultValue
// (the forward-compatible fallback — generator policy).
export class EnumClassValue extends KtSnippet {
type = 'string' as const // TypeSystem contract fields (§1) …
format: string | undefined
enums: string[] | undefined
modifiers: Modifiers
annotations: KtAnnotation[] = [] // KtValueFields — empty here
constants: { annotations: KtAnnotation[]; name: string }[]
constructor({ context, stringSchema, destinationPath, modifiers }: Args) {
super({ context })
this.format = stringSchema.format
this.enums = (stringSchema.enums ?? []).flatMap(entry => (entry === null ? [] : [entry]))
this.modifiers = modifiers
this.constants = this.enums.map(wireValue => ({
// Wire values are data; constant names are identifiers.
name: wireValue.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase(),
annotations: [
new KtAnnotation({
context,
destinationPath,
name: 'JsonProperty',
args: [`"${wireValue}"`],
packageName: 'com.fasterxml.jackson.annotation'
})
]
}))
}
override toString(): string {
const body = this.constants
.map(({ annotations, name }) => `${annotations.map(a => ` ${a}\n`).join('')} ${name},`)
.join('\n\n')
return ` {\n${body}\n}`
}
}
```
One caveat to state rather than discover: an **inline** (property-
level) string-with-enums also routes through this case, and a braced
body is not a valid property type. In the discriminated-union shape
this is harmless — the only inline enums are discriminator tags,
whose parameter entries the union parent removes before render. A
schema with genuine inline enums needs a policy decision first: hoist
the enum to a named schema (`insertNormalizedModel` with a
`fallbackName`) or degrade the property to plain `String`.
### The value protocols — what renders *above* the declaration
The neutral `Lang.toDefinition` call the Drivers make has no
annotations or description slot, so both ride on the **value** and
`KtDefinition` collects them at render:
- **`KtAnnotated`** — a value with an `annotations: KtAnnotation[]`
field. `toKtAnnotations(value)` collects it into a `KtAnnotations`
block rendered one-per-line above the head.
- **`KtDocumented`** (guard: `isKtDocumented`) — a value with a
`description?: string` field, rendered as KDoc above the
annotations. An explicit `description` passed to `KtDefinition` /
`defineAndRegister` wins over the protocol.
Both protocols are read off the **definition's value** —
`thing.value.annotations` / `thing.value.description` — and that is
the only place they live. For a `defineAndRegister` call the value is
the object you passed (the worked example below). For a **Projection**,
the Driver wraps the projection instance itself in the Definition —
the projection IS the definition's value, so the field must sit
**directly on the projection**. When the fact is computed inside a
router case (the routed value carries it), the wiring is
**reference-sharing**: after the single router call, point the
projection's field at the routed value's own array —
`this.annotations = this.value.annotations` — one array, two names;
writes through either are visible to both (the §1 scaffold). Two
disciplines keep it safe: a getter (`get annotations() { … }`) is
still a method and still banned, and neither name may ever be
**reassigned** afterward — push into the array, never replace it; a
later `this.annotations = []` silently splits the two names onto
different arrays with no error anywhere.
### Annotations and defaults are decided inside the per-type snippet
Which serialization annotations to emit, what default value to use,
which access modifier applies — every such decision depends on a fact
somebody already holds, and the rule is to make the decision **where
the fact lives**, then expose the result as a field:
> The router's dispatch answers `schema.type` once. Every
> type-dependent decision is made inside the per-type snippet that
> dispatch constructed, and exposed as a field on the snippet
> (`annotations`, `defaultValue`) for the consuming renderer to read.
> Nothing outside the router ever asks `.type` again — not on a
> schema, not on a routed value.
A helper that takes a schema and asks `resolved.type === 'string'`
after the router already dispatched is asking the already-answered
question a second time — the structural eval's single-dispatch check
flags exactly this, and is right to.
Split every decision by where its fact lives:
- **Type facts → the snippet self-declares.**
`@JsonSerialize(using = MoneyStringSerializer::class)` derives from
`format: 'decimal'`; `@JsonFormat(…)` from `format: 'date-time'`.
The router's `string` case already owns those facts, and it spends
them by CHOOSING a snippet — `BigDecimalValue`,
`OffsetDateTimeValue`, `EnumClassValue`, or plain `StringValue` —
each rendering exactly one Kotlin type and self-declaring the
annotations that type implies as its protocol field
(`annotations: KtAnnotation[]`, the same shape `KtAnnotated` gives
class level). The line to hold: a fork that picks **different
output types** lives in the router case (a schema-fact fork, the
same license as the enums fork); a branch **inside** a snippet is
only for rendering variations of the one type it renders. A
`StringValue` whose `toString()` returns `'BigDecimal'` on one
branch and `'String'` on another is two snippets sharing a file —
split it; the module is named after what it renders (§6F).
- **Defaults are the snippet's own knowledge.** A map snippet knows its
zero value is `'emptyMap()'`; it exposes a `defaultValue` protocol
field rather than letting the renderer inspect
`value.type === 'object'` to decide.
- **Position facts → the parameter renderer, no dispatch needed.**
There are two, and they are decided in the same place: the
`@JsonProperty("user_id")` rename (which needs `wireName` vs the
sanitized name — facts only the object snippet has) and access
control from `readOnly` / `writeOnly`. Access control is the one
that reads like a type fact, because it arrives *on the schema* —
but it belongs to the occurrence, not the type: the same `String`
snippet may sit at a read-only property here and a writable one
there, so a shared per-type snippet cannot carry it without being
wrong at one of the two sites. The parameter renderer reads both per
property and concatenates its own position annotations with the
value's type annotations. When BOTH facts apply to one property,
they combine into a single annotation with two named args —
`@JsonProperty(value = "user_id", access = JsonProperty.Access.READ_ONLY)`
— never two `@JsonProperty` annotations; the single-arg forms are
the degenerate cases. The rule generalizes: **a fact that can
differ between two properties of the same schema type is a position
fact**, whatever it is declared on.
- **Cross-type wire facts → the `in` operator.** `readOnly` /
`writeOnly` are declared on the concrete variants, not on the
`OasSchema` union (`union` and `unknown` have neither), so the read
itself needs `in` — a fact read, not a dispatch:
`'readOnly' in resolved ? resolved.readOnly : undefined`.
- **Cross-type applicability → move the call, don't guard.** If class
annotations don't apply to unions, let the union router case be the
one that doesn't request them — never an internal
`schema.type !== 'union'` guard inside the annotation helper.
The mechanics, on the skeleton's shapes:
```ts fragment
// The generator's value protocol — EVERY routed snippet carries these
// (empty when the type implies nothing; that mild burden is the point:
// consumers read fields, never narrow).
export type KtValueFields = {
annotations: KtAnnotation[]
defaultValue?: Stringable
// Object values only: the entries array their KtParameterList shares —
// exposed so a union parent can reach it through the projection
// (`inserted.definition.value.value.parameters` — the oneOf recipe).
parameters?: KtDataClassParameter[]
}
// The router's own signature — the DEFAULT form: take the union in,
// return the union out, intersected with the protocol. Every case
// returns one union member, so every branch type-checks with no
// casts and no overloads, and protocol reads are cast-free.
export const toKtValue = (
args: TypeSystemArgs<SchemaType>
): TypeSystemValue & KtValueFields => { /* switch (schema.type) … */ }
```
The generic tightening (`<S extends SchemaType>` returning
`TypeSystemOutput<S['type']> & KtValueFields`) exists and is almost
never worth it. Under the doctrine, consumers read protocol fields
and never narrow — so per-type return precision is something a
rule-following consumer cannot even use; the one legitimate
cross-snippet read (`parameters`) is an optional protocol field, not
a narrowed contract field. And the generic form has a real
implementation cost: inside the function body `S` is unresolved, so
TypeScript cannot prove a `switch` branch's concrete snippet against
the conditional return type — which pushes the implementation toward
the banned `as` or an overload pair. Reach for the generic form only
when a call site routes a *statically known* schema type and must
read one of its CONTRACT fields without a guard; prefer widening the
protocol with an optional field even then.
```ts fragment
// Router 'string' case — the fork on schema facts CHOOSES a snippet;
// each target renders exactly one Kotlin type and is its own module
// (§6F: named after what it renders). Nothing inside a snippet
// re-asks format.
case 'string': {
const args = { context, stringSchema: schema, destinationPath, modifiers: { required } }
if (schema.enums) return new EnumClassValue(args)
if (schema.format === 'decimal') return new BigDecimalValue(args)
if (schema.format === 'date-time') return new OffsetDateTimeValue(args)
return new StringValue(args)
}
```
```ts fragment
// BigDecimalValue.ts — one type; its annotations are unconditional
// (the router fork already decided; no format branch survives here).
export class BigDecimalValue extends KtSnippet {
type = 'string' as const // TypeSystem contract fields (§1) — the
format: string | undefined // 'string' OUTPUT contract, whatever renders
// Declared EXACTLY as TypeSystemString declares it — see the
// Nullable-generic note below; narrowing this to `string[]` is the
// one-line change that costs a `deno check` round-trip.
enums: string[] | (string | null)[] | undefined
modifiers: Modifiers
annotations: KtAnnotation[] // ← protocol: self-declared policy
defaultValue?: Stringable
constructor({ context, stringSchema, destinationPath, modifiers }: Args) {
super({ context })
this.format = stringSchema.format
this.enums = stringSchema.enums
this.modifiers = modifiers
this.annotations = [
new KtAnnotation({
context,
destinationPath,
name: 'JsonSerialize',
args: ['using = MoneyStringSerializer::class'],
packageName: 'com.fasterxml.jackson.databind.annotation'
})
]
// Two more imports the leaf owns: the annotation's ARGS name a
// second class (§3, one annotation, two imports), and the rendered
// type itself is not in Kotlin's default imports.
this.register({
imports: {
'com.example.serde': ['MoneyStringSerializer'],
'java.math': ['BigDecimal']
},
destinationPath
})
}
override toString(): string {
return 'BigDecimal'
}
}
// StringValue carries the same contract fields, no annotations, and
// renders 'String'; OffsetDateTimeValue self-declares its
// @JsonFormat and java.time import the same way.
```
**The `Nullable`-generic fields keep their full union.** `enums`,
`default`, and `example` are declared on the schema classes as
conditionals over the class's `Nullable` parameter (`Nullable extends
true ? (string | null)[] | undefined : string[] | undefined`). That
parameter defaults to `boolean | undefined`, so the conditional
distributes and never collapses — `.resolve()` does not pin it to
`false`. At every use site the type is the widened union, and it is
also exactly what the TypeSystem contract declares
(`TypeSystemString.enums` IS `string[] | (string | null)[] |
undefined`). So: copy the contract's declaration verbatim into your
snippet field. Simplifying it to the single arm you expect compiles
in your head and fails at `deno check` — which `skmtc bundle` will
not catch (esbuild does not typecheck). Filter at the point of use
instead, as `EnumClassValue` does
(`.flatMap(entry => (entry === null ? [] : [entry]))`), not by
narrowing the field.
The rule is general — the trio above is not the whole list.
**`OasObject.properties` is the same conditional shape**
(`Record<…> | null | undefined` at use sites), so a presence guard
must clear the `null` arm too: `Object.keys(properties)` behind a
bare `!== undefined` check fails `deno check`; guard with
`properties && Object.keys(properties).length` (truthiness clears
both arms). Before assuming any schema field is plain
`T | undefined`, check its appendix declaration for the `Nullable`
conditional.
```ts fragment
// Consumer side — the object snippet's CONSTRUCTOR makes every
// per-parameter decision, fills the entries array, and builds the
// KtParameterList ONCE, sharing the SAME array instance — so
// generate-time removals (the oneOf recipe) are visible at render.
// No `.type` anywhere: not on schemas, not on routed values.
const entries: KtDataClassParameter[] = []
this.parameters = entries // the KtValueFields slot a union parent may edit
for (const [wireName, property] of Object.entries(properties ?? {})) {
const isRequired = (required ?? []).includes(wireName)
const value = toKtValue({ context, schema: property, destinationPath, required: isRequired })
const inherentDefault = value.defaultValue // e.g. 'emptyMap()'
entries.push({
wireName, // kept for the union recipe's generate-time removal
name: sanitizePropertyName(wireName),
type: value,
// Non-required is nullable UNLESS the type carries its own zero value.
nullable: !isRequired && inherentDefault === undefined,
annotations: [...toPositionAnnotations(wireName), ...value.annotations],
defaultValue: isRequired ? undefined : (inherentDefault ?? 'null')
})
}
this.parameterList = new KtParameterList(entries)
```
```ts fragment
// toString() — pure interpolation of settled state. NOTHING is
// constructed here: no snippets, no KtParameterList wrap, no Error
// (refusals throw from the constructor). The structural eval's
// tostring-purity check flags any `new` inside toString.
override toString(): string {
return `${this.parameterList}`
}
```
One more placement rule the split implies: **facts are declared in
place — no policy module.** The `decimal` branch declares the money
serde annotations where it branches; the `date-time` branch its
`@JsonFormat`; the enum snippet its fallback. A central `policy.ts`
collecting serializer class names, format patterns, and fallback
values re-centralizes what the mapping distributes — a parallel
dispatch table keyed by comments instead of code. A constant used
once belongs at its use site (plain string literals are free
everywhere; hoisting them to another module buys nothing).
Forking *within* a router case on schema facts is the same license
the string case's `format`/`enums` forks use — the case reads a
schema fact and CHOOSES a snippet; it is never a second `.type`
dispatch. The canonical instance: an `additionalProperties`-only
object is a Kotlin `Map`, not a data class, and the split is a
`properties`-presence check — a schema fact, not a type dispatch.
Unlike the string forks (whose targets render different Kotlin types
and are therefore separate snippet modules), the object fork stays
ONE snippet for the whole case, and the stock shape dissolves the
where-do-the-child-snippets-live question: **the TypeSystem contract
fields themselves hold the child snippets** — `objectProperties`
and `recordProperties` are slots the `'object'` output type demands
anyway, each nullable, each holding the snippet that renders that
half. No wrapper, no mirroring: the contract slot IS where the
child snippet lives. (This shape is already worked in full in this
skill — no need to open gen-zod's source to re-derive it.)
```ts fragment
case 'object':
return new ObjectValue({ context, objectSchema: schema, destinationPath, modifiers })
```
```ts fragment
// The skeleton's DataClassValue, generalized (gen-zod's ZodObject
// shape). Contract fields hold the child snippets; toString branches
// on its OWN nullable fields — own state, never schema.type.
export class ObjectValue extends KtSnippet {
type = 'object' as const
// DataClassParameters must ALSO carry a bare `properties:
// Record<string, TypeSystemValue>` field (fill it in the same
// per-property loop that builds `parameters`) — the router returns
// this value as `TypeSystemValue & KtValueFields`, and the contract's
// `TypeSystemObjectProperties` shape requires it structurally or
// `deno check` rejects the 'object' case.
objectProperties: DataClassParameters | null // the parameter-list snippet
recordProperties: MapValue | null // the Map<String, V> snippet
modifiers: Modifiers
annotations: KtAnnotation[] = []
defaultValue?: Stringable
parameters: KtDataClassParameter[] | undefined // KtValueFields slot (same array the parameter-list snippet renders)
constructor({ context, objectSchema, destinationPath, modifiers }: Args) {
super({ context })
this.modifiers = modifiers
const { properties, required, additionalProperties } = objectSchema
this.objectProperties = properties && Object.keys(properties).length
? new DataClassParameters({ context, properties, required, destinationPath })
: null
this.parameters = this.objectProperties?.parameters // reference-share upward for the oneOf recipe
this.recordProperties = additionalProperties
? new MapValue({ context, schema: additionalProperties, destinationPath })
: null
if (this.objectProperties && this.recordProperties) {
// zod composes this case with .and(…); Kotlin has no clean
// data-class ∩ Map form — refuse loudly AT GENERATE (the
// constructor), never at render: toString stays a pure read.
throw new Error('object with both properties and additionalProperties is not mapped')
}
if (!this.objectProperties && this.recordProperties) this.defaultValue = 'emptyMap()'
}
override toString(): string {
return `${this.objectProperties ?? this.recordProperties ?? 'Map<String, Any?>'}`
}
}
```
### Worked example — a serializable DTO, end to end
```ts
import type { GenerateContextType } from '@skmtc/core'
import {
KtAnnotation,
KtParameterList,
createDataClass,
defineAndRegister
} from '@skmtc/lang-kotlin'
type UserValueArgs = {
context: GenerateContextType
destinationPath: string
}
// A value class: renders ONLY what follows the head. The `annotations`
// field is the KtAnnotated protocol — collected by KtDefinition.
class UserValue {
annotations: KtAnnotation[]
parameters: KtParameterList
constructor({ context, destinationPath }: UserValueArgs) {
this.annotations = [
new KtAnnotation({
context,
destinationPath,
name: 'Serializable',
packageName: 'kotlinx.serialization'
})
]
this.parameters = new KtParameterList([
{
name: 'userId',
type: 'String',
annotations: [
new KtAnnotation({
context,
destinationPath,
name: 'SerialName',
args: ['"user_id"'],
packageName: 'kotlinx.serialization'
})
]
},
{ name: 'name', type: 'String' },
{ name: 'email', type: 'String', nullable: true, defaultValue: 'null' }
])
}
toString(): string {
return `${this.parameters}`
}
}
export const writeUser = (context: GenerateContextType): void => {
const destinationPath = '@/com/example/api/User.generated.kt'
defineAndRegister(context, {
identifier: createDataClass('User'),
value: new UserValue({ context, destinationPath }),
destinationPath
})
}
```
Renders — package from the path, imports self-registered by the
annotations, annotation block from the protocol, head from the
identifier, parens from the parameter list:
```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
)
```
*Which* annotations to emit (`kotlinx.serialization` vs Jackson vs
none) is generator policy — this package renders what it is handed and
never names a serialization library.
## 5. Naming & sanitization
- **The export path IS the package.** `KtFile` derives its `package`
directive from its own export path: the segments after `@/` are the
package directories (`@/com/example/api/User.generated.kt` →
`package com.example.api`; `@/models/User.generated.kt` →
`package models`). `client.json#settings.basePath` points at the
Gradle source root (e.g. `consumer/src/main/kotlin`), completing
Kotlin's package-=-folder convention. So a generator's
`toExportPath` is also its package policy — there is no separate
package knob.
- **Path segments must be valid package parts.** `toPackageName`
throws when a directory segment is not a plain Kotlin identifier or
is a hard keyword — `@/my-models/User.kt` (hyphen) or
`@/object/User.kt` (keyword) cannot map to a package. Loud beats
backticked package names. A root-level path (`@/Scratch.kt`) is the
default package: legal, discouraged, renders no `package` line, and
cannot be imported from (§3).
- **Multi-package output** (`client.json#settings.packages`): export
paths are forward paths under a package's `rootPath`
(`my-sdk-core/src/main/kotlin/com/example/User.kt`), and the package
is derived with the owning `rootPath` stripped. Each `rootPath` is
that module's Gradle source root, exactly as `basePath` is in
single-package mode. Cross-module imports resolve to the target
module's real dotted package.
- **`sanitizePropertyName(name)`** — makes a property name safe as a
Kotlin declaration name. Plain identifier → as-is; hard keyword
(`object`, `val`, …) or invalid name (`user name`, `1st`) →
backticked (`` `object` ``); a name backticks cannot save (contains
`.` `;` `:` `/` `\` `[` `]` `<` `>` a backtick or newline — illegal
on the JVM even escaped) → throws. Returns a plain `string` (no
quoted-property fallback exists in Kotlin).
- **Escaping is lang-side backticks; renames are gen-side
annotations.** The two compose, and the split decides your
snake_case strategy: keep the wire name and backtick if needed
(`` `object` `` still equals its wire name — no annotation), or
rename to camelCase and annotate
(`@SerialName("user_id") val userId`). Renaming is generator
policy; this package only guarantees the chosen name parses.
- **File naming**: `.kt` extension; the engine injects the
generated-file infix (`client.json#settings.generatedSuffix`,
default `'.generated'`) into `toExportPath` before the extension —
`User.kt` lands as `User.generated.kt`, idempotently, exactly as
for any language. Keep the convention — including when replacing a
hand-written file: Kotlin resolves by **package**, not filename, so
`Dtos.generated.kt` replaces a hand-written `Dtos.kt` without
touching the app, and the engine-owned marker survives. An exact
filename genuinely matters only when something outside Kotlin keys
on it (a build script, a TS import); only then set
`client.json#settings.generatedSuffix: ""` rather than fighting the
suffix in the path policy. Class names come from the refName; the
engine's cache keys on `(identifier.name, exportPath)`.
## 6. Kotlin-output anti-patterns
- **Baking the head into the value** — a value `toString()` returning
`data class User(…)` or `private class Foo`. The identifier renders
the keyword, name, visibility, and `: TypeName`; the definition adds
`${head}` — doubling them emits `data class User data class User(…)`.
Return only what follows the head (§2).
- **Adding delimiters outside the value** — wrapping a value in
`(${value})` or `{ ${value} }` at the call site. Values own their
delimiters: `KtParameterList` brings its parens, a body value brings
its ` {\n…\n}`. If the output is missing parens, fix the value, not
the caller.
- **A manual import register next to a `KtAnnotation`** — registering
`imports: { 'com.fasterxml.jackson.annotation': ['JsonProperty'] }`
by hand while also constructing the annotation. Pass `packageName`
to the annotation and delete the manual register; two statements
drift apart (§3).
- **Hand-rendering import or package statements in template
literals** — they land in the file *body* (invalid Kotlin below the
real header) and bypass sorting, dedup, and same-package
suppression. `KtFile` owns the header; generators only register.
- **Type-tagging imports or building barrels** — TypeScript instincts
with no Kotlin counterpart: no type-only imports, no re-exports.
The vocabulary omits both; don't emulate them with strings.
- **Faking raw file content as a definition** — an identifier whose
name never appears in the emitted code nullifies what an identifier
is. A leading banner belongs on the register vocabulary's `custom`
slot (§3); whole-file static content is a FILE fact, not a
definition.
- **Producer logic in private methods** — a `private
toAnnotations()` on a value class or projection breaks the
constructor + `toString()` contract exactly as a public method
does (`skmtc-generator` §2: private helpers and accessors count).
Annotation-building and union-membership branching belong in
module-level free functions taking `{ context, … }` that construct
`KtAnnotation` / snippet leaves — the leaves then self-register
their imports.
- **Mirroring protocol fields** — a getter
(`get annotations() { return this.value.annotations }` — a method;
producers are constructor + `toString()` only, and the structural
eval's method-discipline check counts accessors), a **copy**
(`this.annotations = [...this.value.annotations]` — a second array,
two facts that will drift), or **reassigning an aliased name**
(`this.annotations = []` after the alias — silently splits the two
names onto different arrays). What IS sanctioned is
reference-sharing: `this.annotations = this.value.annotations` in
the constructor points the projection's protocol slot at the routed
value's own array — one array, two names — and is the canonical
wiring (§4); after that line, push, never replace.
- **Re-deriving schema facts outside the router** — an annotation or
default-value helper that takes a schema and asks
`resolved.type === 'string'` (or narrows a routed value with
`value.type === 'object'`) after the router already dispatched. The
mapping extracted that fact once; policy consumes it as a protocol
field (`annotations` / `defaultValue` — §4) or an `in` fact-read,
never by a second dispatch. The single-dispatch check flags every
such site.
- **Running a formatter over the output** — render is unformatted by
design; the consumer's ktfmt/ktlint formats. Trailing commas,
line-wrapping, and indentation niceties are their territory.
- **Mismatched `lang-kotlin` pins across the stack** — two copies of
the package (e.g. a local generator pinning a different version
than a JSR peer) break cross-copy `instanceof`: `KtFile` /
`KtIdentifier` checks fail in ways that look like engine bugs. Every
package in one build must pin the SAME `@skmtc/lang-kotlin` version.
- **Export paths that cannot be packages** — `@/my-models/…`,
`@/models/v1.2/…`, a hard-keyword segment. `toPackageName` throws at
render, far from the `toExportPath` that caused it; fix the path
policy (§5).
## 7. Boundary with other skills
- **`skmtc-generator`** — everything engine-side: Projections vs
Snippets, `transform` returning void, `insertModel` /
`insertOperation` / `findDefinition`, the constructor/`toString()`
contract, enrichments, variants, cross-generator coordination. If
the question is "how do generators work", it's there; if it's "what
does the emitted Kotlin look like", it's here.
- **`skmtc-cli`** — install/bundle/generate commands, registering a
local generator in a workspace.
- **`skmtc-debug`** — broken output, verify-first stance.
- **`skmtc-lang-typescript`** — the TypeScript counterpart and the
template this skill follows; useful contrastively (type-only
imports, re-exports, and quoted-property fallbacks exist THERE, not
here).
### Status note
Current model: the **head+value rewrite** — the identifier renders its
declaration head, the value renders everything after it, and the
former `KtSupertyped` / `KtConstructed` value protocols and the
`verbatim` identifier kind are gone (historical: supertype clauses and
braced bodies are now written inline; primary constructors compose
`KtPrimaryConstructor`; raw file content goes through the `custom`
slot). `KtFile.header` / `fileHeader` were superseded by `custom`.
The package lives at `deno/lang-kotlin` (see its `deno.json` for the
current version; released through the workspace cascade against the
pinned `@skmtc/core`). Proving generators: the `gen-kotlin*` family —
being rewritten onto this model; the migration log is
`notes/lang-v2/02-lang-kotlin-changes-for-generator-rewrite.md`.
<!-- 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 `71ef53bc`, 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 -->