Surefold

Guide

Kimney

A K2 compiler plugin that writes the mapping code between your Kotlin types at compile time, inspired by Scala's Chimney. You say what turns into what; the compiler writes the constructor calls, or refuses to compile and tells you every reason why.

Plugin id
io.github.matthewjones372.kimney
Latest
0.3.0
Needs
Kotlin 2.4, JVM
Licence
Apache 2.0

What it is

Kimney is a compiler plugin for the mapping code every layered Kotlin service carries: domain to DTO, row to entity, event to message. You write one call, such as transformInto(). At compile time Kimney works out the constructor calls, the nested mappings, the when over enums and sealed types, and the collection loops, and generates exactly that code.

There is no reflection, no runtime mapping table and no annotation processor. What runs is what you would have written by hand. When a mapping cannot be derived, the call does not compile, and the error names every field it could not fill, the path to it and what would fix it.

The layer tax

Domain-driven code gives one idea several shapes on purpose. An order is a PlaceOrderRequest at the edge, a PlaceOrder command, an Order aggregate, an OrderPlaced event, an OrderRow in storage and an OrderView going back out. Each says which layer you are in, and each can change for its own reasons.

The cost is the mapping between them. Every pair of shapes is a function that restates every field, and every new field is one more line in each. Under that weight teams collapse the layers: the entity doubles as the DTO, the event reuses the row. Kimney removes the cost and leaves the design. Each crossing becomes one line, derived again on every build, so it follows the types as they change. The first recipe walks one order through all six shapes.

Mappers that go stale

A hand-written mapper is right on the day it is written, and nothing in Kotlin tells you when it stops being right. These two bugs both compile:

example/src/main/kotlin/example/cookbook/why/Drift.kt
package example.cookbook.why

import io.github.matthewjones372.kimney.transformInto

data class Signup(val email: String, val marketingConsent: Boolean)

// `marketingConsent` was added to both classes after the hand-written mapper below. Its default keeps old
// callers compiling, which is exactly why nothing tells the mapper it is now wrong.
data class SignupDto(val email: String, val marketingConsent: Boolean = false)

fun Signup.toDtoByHand(): SignupDto = SignupDto(email = email)

fun Signup.toDto(): SignupDto = transformInto()

enum class Plan { FREE, PRO, ENTERPRISE }

enum class PlanDto { FREE, PRO, UNKNOWN }

// Written when there were two plans. ENTERPRISE arrived later and fell into the `else` without a word.
fun Plan.toDtoByHand(): PlanDto = when (this) {
    Plan.FREE -> PlanDto.FREE
    Plan.PRO -> PlanDto.PRO
    else -> PlanDto.UNKNOWN
}

The consent that disappears. toDtoByHand still compiles after marketingConsent is added, and records every signup as not consenting. The Kimney toDto needs no edit: it is derived again, finds the new property and carries it.

The plan that becomes UNKNOWN. ENTERPRISE falls into the else. Kimney has no else to fall into: a source entry the target lacks is a compile error at every call that meets it. Adding ENTERPRISE to PlanDto is the whole fix.

When this happensBy handWith Kimney
A field is added to both types, with a default on the targetCompiles, and silently uses the defaultMapped
A field is added to the target only, no defaultCompile error in the mapperCompile error naming the field and how to fill it
A field is renamed on one side, no defaultCompile error in the mapperCompile error naming the field; one withFieldRenamed fixes it
A target field with a default has no source of that nameThe default, silentlyThe default, silently (on purpose)
A new enum entry or sealed caseCaught only if the when has no elseCompile error at every call that meets it
An optional nested object?.let { … } by handDerived, null kept as null
A List<Line> of nested types.map { … } around another mapperDerived, element by element
A String? flowing into a String!!, ?: "" or a bugCompile error that says null is the reason

When not to use it

  • The mapping is mostly logic. If most fields are computed from several others, a plain function says that better than a chain of withFieldComputed.
  • You have three small mappers. Drift is a cost that grows with the number of pairs and fields. For a handful, write them by hand.
  • You cannot stay on Kotlin 2.4. A compiler plugin runs inside the compiler, and a build on another minor stops at configuration.
  • You need a 1.0. Kimney is 0.x: on Maven Central, but its surface may still change between releases, and editor support is unchecked.
  • You need overrides on nested fields. They are not there yet. A transformer for the nested pair covers them today.

Install

Kimney is on Maven Central, with the Gradle plugin and its marker. It is not on the Gradle Plugin Portal, so pluginManagement must name Central.

settings.gradle.kts
pluginManagement {
    repositories {
        mavenCentral()
        gradlePluginPortal()
    }
}
dependencyResolutionManagement {
    repositories { mavenCentral() }
}
build.gradle.kts
plugins {
    kotlin("jvm") version "2.4.10"
    id("io.github.matthewjones372.kimney") version "0.3.0"
}

That is all. The Gradle plugin adds io.github.matthewjones372:kimney-runtime to every JVM compilation and loads kimney-compiler-plugin into it. You do not add either dependency yourself. Everything you call is in the package io.github.matthewjones372.kimney.

Kotlin versions

A compiler plugin runs inside the compiler, whose plugin API promises nothing from one minor to the next, so each release names the Kotlins it supports. Since 0.2.0 that is any Kotlin 2.4. Every compiler test runs on 2.4.0, 2.4.10 and 2.4.20 in CI.

  • A tested version (2.4.0 to 2.4.20) applies quietly.
  • A newer 2.4 patch applies with a warning that it is untested.
  • Another minor, or anything older than 2.4.0, stops the build at configuration, naming the range.

The check happens in Gradle rather than in the compiler on purpose: a plugin loaded into the wrong compiler fails with a NoSuchMethodError deep inside it, which reads like a compiler bug. 0.1.0 supported 2.4.10 alone.

IDE support

IntelliJ's K2 mode loads only bundled compiler plugins by default, so Kimney's errors appear when Gradle compiles, not in the editor as you type. The IDE has not been checked with the plugin enabled. The errors themselves come from the K2 frontend checker, the same phase IDE analysis runs, rather than from code generation.

Maven Local

To try an unreleased change, clone kimney, run publishToMavenLocal there, and put mavenLocal() first in both repository lists in your settings.gradle.kts.

In the kimney checkout
./gradlew publishToMavenLocal

Then use the -SNAPSHOT version from kimney's gradle.properties, currently 0.4.0-SNAPSHOT. Signing is skipped for a snapshot, so no key is needed.

Composite builds

To work on kimney and a project together, include both builds. The Gradle plugin is a build of its own, so it goes in pluginManagement:

settings.gradle.kts
pluginManagement {
    includeBuild("../kimney/kimney-gradle-plugin")
}
includeBuild("../kimney")

From 0.4.0 (not yet released) the plugin supports Gradle's configuration cache: a consumer build is stored, reused and still derives on every tested Kotlin.

Your first mapping

  1. Write the two types and the call

    Same-named properties fill the target's constructor. What the target does not ask for, admin here, is left behind.

    example/src/main/kotlin/example/cookbook/first/FirstTransform.kt
    package example.cookbook.first
    
    import io.github.matthewjones372.kimney.transformInto
    
    data class User(val name: String, val email: String, val admin: Boolean)
    
    data class UserDto(val name: String, val email: String)
    
    fun User.toDto(): UserDto = transformInto()
  2. Wrap it in an extension function

    The shape fun User.toDto(): UserDto = transformInto() is the one worth copying. The return type names the target, so the call needs no type argument, and callers read user.toDto(). Where the target is not in the return type, name it: user.transformInto<UserDto>().

  3. Build

    The plugin replaces the call with the code you would have written. The source is evaluated once. For this file, toDto compiles to:

    What the call compiles to
    UserDto(name, email)

    No reflection, no mapping table, nothing at runtime a hand-written mapper would not have.

  4. Break it on purpose

    Add val phone: String to UserDto and build again. The call no longer compiles, and the message names the field and three ways to fill it (add it to the source, give it a default, or override it). When it cannot lists every message.

How derivation works

Kimney looks at one pair of types at a time: the source and the target. For each pair it tries these rules in order and uses the first that applies. Each nested pair (a field, a list element, a sealed case) goes through the same list again, as deep as it goes.

#RuleWhat it doesExample
1OverrideA withFieldConst, withFieldComputed or withFieldRenamed fills the top-level field it names, ahead of everything else..withFieldConst(PersonDto::source, "import")
2Your transformerA Transformer passed with withTransformer or in a context parameter, for every pair below the root it fits..withTransformer(userToDto) for each User → UserDto
3IdentityA source that is already a subtype of the target is used as it is (rebuilt if the call has overrides).String → String
4Nullable targetS → T? is S → T. S? → T? is S → T on the non-null value, null kept as null.Address? → AddressDto?
5Value classA value class target wraps, a value class source unwraps, whatever the held property is called.UserId(raw: Long) → Long, UserId → OwnerId
6ContainersList, Set, Collection, Iterable, Map, Array and the mutable kinds, element by element, order kept.List<Line> → List<LineDto>
7ObjectAn object target is its instance, from an object source only.Payment.Cash → PaymentDto.Cash
8EnumEach source entry becomes the target entry of the same name.Status.ACTIVE → StatusDto.ACTIVE
9SealedEach direct subclass becomes the target subclass of the same simple name, each derived by every rule.Payment.Card → PaymentDto.Card
10ConstructorThe target's public primary constructor. Each parameter takes the same-named public source property, else its default, else it is a failure.User → UserDto(name, email)

Anything none of these covers is a compile error. Some consequences worth knowing:

  • There is no numeric widening and no toString. Int into Long is no rule transforms Int into Long.
  • Source properties the target does not ask for are ignored.
  • The constructor rule builds a final or open Kotlin class outside the standard library. A generic target is built with its type arguments substituted.
  • A Java platform type (String!) counts as non-null, as Kotlin lets it be used.

Inside, a pure engine (kimney-derive) turns a source and target into either a plan or the full list of reasons it cannot. The plugin runs it twice: over the K2 frontend, so the checker reports errors on the call, and over IR, so the lowering generates the plan. Both run the same engine, so the checker and the code generator cannot disagree about a rule.

Overrides

Where names or values differ, start a chain with into<_, Target>() instead of transformInto(), add overrides, and end it with .transform(). The _ lets Kotlin infer the source while you name the target. Every override is type-checked.

CallSignature on Into<A, B>Does
withFieldConst<T> withFieldConst(field: KProperty1<B, T>, value: T)Fills field with value
withFieldComputed<T> withFieldComputed(field: KProperty1<B, T>, compute: (A) -> T)Fills field with the lambda's result on the source
withFieldRenamed<S, T> withFieldRenamed(from: KProperty1<A, S>, to: KProperty1<B, T>)Fills to from source property from, transformed by the usual rules
withTransformer<S, T> withTransformer(transformer: Transformer<S, T>)Uses your function for every nested pair it fits
withPartialTransformer<S, T> withPartialTransformer(transformer: PartialTransformer<S, T>)The same, for a function that can fail, in partial calls only
withEnumEntryRenamed<S : Enum<S>, T : Enum<T>> withEnumEntryRenamed(from: S, to: T)Sends one entry to another
withEnumFallback<T : Enum<T>> withEnumFallback(to: T)Sends every unmatched entry to one
withSealedCaseRenamed<S : Any, T : Any> withSealedCaseRenamed(from: KClass<S>, to: KClass<T>)Sends one case to another
withSealedFallback<T : Any> withSealedFallback(to: T)Sends every unmatched case to one object
transformtransform(): BEnds the chain
transformPartialtransformPartial(): Partial<B>Ends the chain, collecting errors instead

Field overrides

Each field override fills one top-level constructor parameter of the target and wins over every other way of filling it.

example/src/main/kotlin/example/cookbook/overrides/Overrides.kt
package example.cookbook.overrides

import io.github.matthewjones372.kimney.into

data class Person(val fullName: String, val born: Int, val email: String)

data class PersonDto(val name: String, val age: Int, val email: String, val source: String)

fun Person.toDto(year: Int): PersonDto = into<_, PersonDto>()
    .withFieldRenamed(Person::fullName, PersonDto::name)
    .withFieldComputed(PersonDto::age) { year - it.born }
    .withFieldConst(PersonDto::source, "import")
    .transform()
  • withFieldRenamed reads another source property, transformed by the usual rules.
  • withFieldComputed runs a lambda on the source. It compiles to a direct call of the body: no function object is created.
  • withFieldConst gives a value.

The source is evaluated once, then each override's expression in the order written, then the constructor. email is still derived by name.

With an override, a source of the target's own type is rebuilt rather than passed through, which gives you a copy with changes even for a class with no copy:

example/src/main/kotlin/example/cookbook/copy/CopyWithChanges.kt
package example.cookbook.copy

import io.github.matthewjones372.kimney.into

data class Account(val id: Long, val owner: String, val suspended: Boolean)

fun Account.suspend(): Account = into<_, Account>()
    .withFieldConst(Account::suspended, true)
    .transform()

One chain, one expression

The plugin reads the chain at compile time, so it must be one expression from into() to .transform(), with property references written out and a lambda for withFieldComputed. A builder stored in a val, a property reference passed in as a variable, a function reference instead of a lambda, or your own extension in the middle of the chain is a compile error:

Does not compile
fun stored(user: User): UserDto {
    val builder = user.into<_, UserDto>()
    return builder.withFieldConst(UserDto::source, "import").transform()
}
Compiler output
Cannot transform User → UserDto:
    Into<User, UserDto> — the overrides must be one chain from into() to .transform(), written as a single expression with property references like UserDto::name and a lambda for withFieldComputed.

Enum mappings

An enum mapping decides what entries become wherever its two enums meet in the derivation: at the root, in a field, as an element or inside a sealed case. For each entry Kimney tries a rename first, then the entry of the same name, then the fallback.

example/src/main/kotlin/example/cookbook/entries/EnumEntries.kt
package example.cookbook.entries

import io.github.matthewjones372.kimney.into

// The domain archives accounts; the API calls that inactive.
enum class Status { ACTIVE, SUSPENDED, ARCHIVED }

enum class StatusDto { ACTIVE, SUSPENDED, INACTIVE }

fun Status.toDto(): StatusDto = into<_, StatusDto>()
    .withEnumEntryRenamed(Status.ARCHIVED, StatusDto.INACTIVE)
    .transform()

// A partner's enum grows without asking; everything we do not model is UNKNOWN.
enum class PartnerStatus { OPEN, CLOSED, ON_HOLD, ESCALATED }

enum class TicketStatus { OPEN, CLOSED, UNKNOWN }

fun PartnerStatus.toTicket(): TicketStatus = into<_, TicketStatus>()
    .withEnumFallback(TicketStatus.UNKNOWN)
    .transform()

The arguments must be the entries as written, Status.ARCHIVED, not a variable holding one. The fallback is also what an entry compiled in after the call becomes; without one, such an entry throws NoWhenBranchMatchedException.

Sealed mappings

Sealed types get the same two calls. For each source case Kimney tries a rename, then the case of the same name, then a transformer from the case into the target's sealed parent, then the fallback object.

From the reference
val message = event.into<_, Message>()
    .withSealedCaseRenamed(Event.Escalated::class, Message.Raised::class)
    .withTransformer(Transformer<Event.Merged, Message> { Message.Closed(it.ticket, "merged") })
    .withSealedFallback(Message.Ignored)
    .transform()

A rename takes class literals (Event.Escalated::class), and a fallback takes the object itself (Message.Ignored). The renamed case is derived by every rule, so it still picks up defaults. The event to message recipe has the full file.

Transformers

A Transformer<A, B> is a fun interface with one method, transform(source: A): B. Passed with withTransformer, it is tried first on every pair below the root: fields, list elements, map values and sealed cases. It fits a pair S → T when S is a subtype of A and B a subtype of T, so a Transformer<Person, UserDto> serves a User that extends Person.

example/src/main/kotlin/example/cookbook/transformers/Transformers.kt
package example.cookbook.transformers

import io.github.matthewjones372.kimney.Transformer
import io.github.matthewjones372.kimney.into

data class User(val fullName: String, val email: String)

data class UserDto(val name: String, val email: String)

data class Team(val lead: User, val members: List<User>, val motto: String?)

data class TeamDto(val lead: UserDto, val members: List<UserDto>, val motto: String)

/** How a User becomes a UserDto, written once, with kimney itself. */
val userToDto = Transformer<User, UserDto> {
    it.into<_, UserDto>().withFieldRenamed(User::fullName, UserDto::name).transform()
}

/** A null gets a value only where someone says which. */
val noMotto = Transformer<String?, String> { it ?: "(none)" }

fun Team.toDto(): TeamDto = into<_, TeamDto>()
    .withTransformer(userToDto)
    .withTransformer(noMotto)
    .transform()

userToDto serves both lead and every element of members. noMotto does what no rule will: it is how a String? becomes a String, with the choice written down. Each transformer is evaluated once, in chain order. The root pair belongs to the chain itself and never goes to a transformer.

Context transformers

A transformer every mapping in a layer should use (how money is shown, how an id is encoded) can sit in a context parameter instead. Every transformInto and chain beneath it uses it for the pairs it fits, innermost first.

example/src/main/kotlin/example/cookbook/context/ContextTransformers.kt
package example.cookbook.context

import io.github.matthewjones372.kimney.Transformer
import io.github.matthewjones372.kimney.transformInto

data class Money(val pence: Long, val currency: String)

data class MoneyDto(val display: String)

data class Invoice(val total: Money, val tax: Money)

data class InvoiceDto(val total: MoneyDto, val tax: MoneyDto)

/** How money is shown, decided once for the whole API layer. */
val showMoney = Transformer<Money, MoneyDto> {
    MoneyDto("${it.currency} ${it.pence / 100}.${(it.pence % 100).toString().padStart(2, '0')}")
}

// `this.` is required: a context parameter is also an `Any?`, so a bare transformInto() could mean either.
context(money: Transformer<Money, MoneyDto>)
fun Invoice.toDto(): InvoiceDto = this.transformInto()

fun present(invoice: Invoice): InvoiceDto = context(showMoney) { invoice.toDto() }

A context transformer is never reported as unused, since it is there for every call below it. If one passed with withTransformer and one in context both fit a pair, the error names both.

Partial transformations

At the edge, where data might not fit, transformIntoPartial<B>() (or a chain ending in .transformPartial()) returns a Partial<B> instead of refusing what might fail. It derives by every rule above, with two differences:

  • S? → T is allowed. A null records PartialError(path, "is null").
  • A constructor or value class that throws IllegalArgumentException (a require, say) records its message at the path of what it was building.

The result is Partial.Ok(value) if nothing was recorded, or Partial.Errors(errors) with every PartialError(path, message) in the order met. valueOrNull() gives the value or null. Other exceptions propagate, and a pair no rule connects is still a compile error: partial mode relaxes what may fail at runtime, not what can be derived.

A PartialTransformer<A, B> returns a Partial<B> and is passed with withPartialTransformer or in a context parameter. Its errors are re-rooted where its value sits (see Partial.Errors.relocatedTo). The validating and parsing recipes show both.

Types it covers

Nested classes and defaults

A nested pair is derived by the same rules, as deep as it goes. A target parameter with a default takes it when the source has nothing of that name.

example/src/main/kotlin/example/cookbook/nested/NestedAndDefaults.kt
package example.cookbook.nested

import io.github.matthewjones372.kimney.transformInto

data class Address(val street: String, val zip: String)

data class Customer(val name: String, val address: Address)

data class AddressDto(val street: String, val zip: String, val country: String = "GB")

data class CustomerDto(val name: String, val address: AddressDto, val tier: String = "standard")

fun Customer.toDto(): CustomerDto = transformInto()

toDto gives CustomerDto(name, AddressDto(street, zip, "GB"), "standard"). The target needs a public primary constructor; it can be a data class or any final or open Kotlin class outside the standard library.

Enums

Each source entry becomes the target entry of the same name. Extra target entries are fine; a source entry the target lacks is a compile error.

example/src/main/kotlin/example/cookbook/enums/Enums.kt
package example.cookbook.enums

import io.github.matthewjones372.kimney.transformInto

enum class Status { ACTIVE, SUSPENDED }

enum class StatusDto { ACTIVE, SUSPENDED, UNKNOWN }

fun Status.toDto(): StatusDto = transformInto()

Entries are compared by identity, never by ordinal, so an enum from another module can be reordered safely. For entries with different names, use enum mappings.

Sealed types

Each case becomes the target case of the same simple name, and each pair goes through every rule, so Card picks up its default network and Cash maps object to object.

example/src/main/kotlin/example/cookbook/sealed/Sealed.kt
package example.cookbook.sealed

import io.github.matthewjones372.kimney.transformInto

sealed interface Payment {
    data class Card(val last4: String) : Payment

    data class Transfer(val iban: String) : Payment

    data object Cash : Payment
}

sealed interface PaymentDto {
    data class Card(val last4: String, val network: String = "unknown") : PaymentDto

    data class Transfer(val iban: String) : PaymentDto

    data object Cash : PaymentDto
}

fun Payment.toDto(): PaymentDto = transformInto()

A case added to Payment without one in PaymentDto stops the build at every call that meets it. A single case into a sealed target (Payment.Card into PaymentDto) takes the target's case of the same name. The generated when ends in the throwing else an exhaustive hand-written when compiles to, so a case added to a source compiled elsewhere fails the same way it would by hand.

Generic sealed types work too. A case gets its type arguments from where its sealed supertype names them directly: Found<T> : Lookup<T> meeting Lookup<Product> is Found<Product>.

example/src/main/kotlin/example/cookbook/results/Results.kt
package example.cookbook.results

import io.github.matthewjones372.kimney.transformInto

sealed interface Lookup<out T> {
    data class Found<T>(val value: T) : Lookup<T>

    data class Missing(val key: String) : Lookup<Nothing>
}

sealed interface LookupView<out T> {
    data class Found<T>(val value: T) : LookupView<T>

    data class Missing(val key: String) : LookupView<Nothing>
}

data class Product(val sku: String, val pence: Long)

data class ProductView(val sku: String, val pence: Long)

fun Lookup<Product>.toView(): LookupView<ProductView> = transformInto()

Optionals

S? → T? derives S → T behind a null check, reading the source once. S → T? just derives S → T. So every other rule also works inside an optional.

example/src/main/kotlin/example/cookbook/optionals/Optionals.kt
package example.cookbook.optionals

import io.github.matthewjones372.kimney.transformInto

data class Address(val street: String)

data class Profile(val nickname: String, val billing: Address?)

data class AddressDto(val street: String)

data class ProfileDto(val nickname: String?, val billing: AddressDto?)

fun Profile.toDto(): ProfileDto = transformInto()

The other direction, String? → String, is refused because a null would have nowhere to go. Make the target nullable, fill it with an override or a transformer, or use a partial transformation.

Value classes

A value class source unwraps and a value class target wraps, whatever the held property is called. A domain UserId meets a Long column, and another value class, through the value inside.

example/src/main/kotlin/example/cookbook/ids/ValueClasses.kt
package example.cookbook.ids

import io.github.matthewjones372.kimney.transformInto

@JvmInline
value class UserId(val raw: Long)

@JvmInline
value class OwnerId(val value: Long)

data class User(val id: UserId, val name: String)

data class UserRow(val id: Long, val name: String)

data class Document(val owner: OwnerId)

fun User.toRow(): UserRow = transformInto()

fun UserRow.toUser(): User = transformInto()

fun UserId.toOwnerId(): OwnerId = transformInto()

Collections and maps

A List, Set, Collection, Iterable, Map or Array<T> pair transforms element by element, each element by every rule, into the same kind or a read-only supertype of it (List into Iterable). Order is kept. A map's values take every rule; its keys take only identity or a value class.

example/src/main/kotlin/example/cookbook/collections/Collections.kt
package example.cookbook.collections

import io.github.matthewjones372.kimney.transformInto

@JvmInline
value class Sku(val code: String)

enum class Tag { GIFT, FRAGILE }

enum class TagDto { GIFT, FRAGILE }

data class Line(val sku: Sku, val quantity: Int)

data class LineDto(val sku: String, val quantity: Int)

data class Order(val lines: List<Line>, val tags: Set<Tag>, val stock: Map<Sku, Int>)

data class OrderDto(val lines: List<LineDto>, val tags: Set<TagDto>, val stock: Map<String, Int>)

fun Order.toDto(): OrderDto = transformInto()

The generated code is the loop map compiles to: one new collection, sized to the source where its size is known, and no lambda.

MutableList, MutableSet, MutableCollection, MutableIterable and MutableMap work too. A mutable source acts as its read-only kind. A mutable target is always a new collection, even from a source of its own type, so neither side's edits reach the other.

From the plugin's tests
fun mutable(lines: List<Line>): MutableList<LineDto> = lines.transformInto<MutableList<LineDto>>()

Crossing kinds is refused: Set into List would have to pick an order, and List into Set would drop duplicates. The Set into List recipe shows the fix.

Generics

A generic target is built with its type arguments substituted, and each field is derived for the concrete types. So Box<Int> into Box<Long> fails on Box<Long>.value with no rule transforms Int into Long, just as it would for a non-generic class. Generic sealed hierarchies are covered above.

Recursive types

A comment thread, a category tree or an org chart derives like any other type, to whatever depth the value has.

example/src/main/kotlin/example/cookbook/trees/Trees.kt
package example.cookbook.trees

import io.github.matthewjones372.kimney.transformInto

data class Comment(val author: String, val text: String, val replies: List<Comment>)

data class CommentView(val author: String, val text: String, val replies: List<CommentView>)

fun Comment.toView(): CommentView = transformInto()

When a pair meets itself below its own derivation, Kimney writes that part as a local function inside the call and calls it again where the pair recurs. Nothing is added to the class or the file. Mutually recursive pairs (a Folder holding a File holding a Folder) work the same way.

When it cannot

A mapping Kimney cannot derive is a compile error on the call, with the diagnostic id KIMNEY_CANNOT_TRANSFORM. It lists every problem at once, not the first, so one build shows you everything to fix.

Reading an error

Compiler output
e: Main.kt:12:5 Cannot transform User → UserDto:
    UserDto.email: String — User has no property 'email'. Add it to User, give UserDto.email a default value, or add .withFieldConst(UserDto::email, …).
    UserDto.address.zip: String — Address has no property 'zip'. Add it to Address, or give AddressDto.zip a default value. Or map Address → AddressDto with .withTransformer(Transformer<Address, AddressDto> { … }).

Each line has three parts:

  • The path from the target, with its type: UserDto.address.zip: String. A list element is written lines[], a map key keyed[key], and a sealed case by its name, ShapeDto.Circle.radius.
  • The reason, after the dash.
  • The fix, as code you can paste. The first failure inside a nested class also offers a transformer for that pair.

The messages below are copied from the plugin's test goldens. The kimney build fails if its docs quote a message nothing produces.

Derivation errors

CauseMessageFix
A target field nothing fillsUserDto.email: String — User has no property 'email'. Add it to User, give UserDto.email a default value, or add .withFieldConst(UserDto::email, …).One of the three it names, or withFieldRenamed if the source calls it something else.
String? into StringStrictDto.name: String — User.name is String?, and a null has nowhere to go. Make StrictDto.name nullable, or fill it with .withFieldComputed(StrictDto::name) { … }.Make the target nullable, compute it, pass a Transformer<String?, String>, or use transformIntoPartial.
Nullable elements into non-nullList<String>[]: String — an element of List<String?> is String?, and a null has nowhere to go. Transform into String? instead.As above.
Two types no rule connectsOrderDto.count: Long — no rule transforms Int into Long.Change a type, compute the field, or pass a Transformer<Int, Long>.
The same, inside a nested classShapeDto.Circle.radius: Double — no rule transforms Int into Double. Or map Shape.Circle → ShapeDto.Circle with .withTransformer(Transformer<Shape.Circle, ShapeDto.Circle> { … }).The transformer it names, or fix the types.
An enum entry the target lacksStatusDto — Status.ARCHIVED has no entry of the same name in StatusDto. Map it with .withEnumEntryRenamed(Status.ARCHIVED, StatusDto.…), or send every unmatched entry to one with .withEnumFallback(StatusDto.…).Add the entry, rename it or add a fallback.
A sealed case the target lacksShapeDto — Shape.Hexagon has no subclass of the same name in ShapeDto. Map it with .withSealedCaseRenamed(Shape.Hexagon::class, ShapeDto.….class), build it with .withTransformer(Transformer<Shape.Hexagon, ShapeDto> { … }), or send every unmatched case to one object with .withSealedFallback(ShapeDto.…).Add the case, or one of the three it names.
A Set into a ListStrictOrder.tags: List<TagDto> — a Set is not turned into a List. Fill it with .withFieldComputed(StrictOrder::tags) { … }.Compute it and decide the order (see the recipe).
A map key that could collideStrictOrder.keyed[key]: LineDto — keys are transformed only as themselves or through a value class, since Line into LineDto could turn two keys into one.Keep the key type, use a value class, or compute the map.
No usable constructorHidden — Hidden has no public primary constructor: it is private.
SecondaryOnly — SecondaryOnly has no public primary constructor: it has only secondary constructors.
Give the target a public primary constructor, or build it with a transformer.
A failable transformer in a total callBooking.checkIn: Int — the transformer that fits String → Int can fail. End the chain with .transformPartial().End with .transformPartial().

Override errors

CauseMessage
The field is not a constructor parameterUserDto.nickname — withFieldConst names 'nickname', which is not a constructor parameter of UserDto.
One field overridden twiceUserDto.name — overridden twice, by withFieldRenamed and withFieldConst. Keep one.
A constant of the wrong typeUserDto.age: Long — withFieldConst gives String, which is not a Long.
A renamed source Kimney cannot readUserDto.name: String — withFieldRenamed reads User.inherited, which kimney cannot read: it is inherited, an extension or not public.
The chain is not one expressionInto<User, UserDto> — the overrides must be one chain from into() to .transform(), written as a single expression with property references like UserDto::name and a lambda for withFieldComputed.
Two transformers fit one pairTeamDto.lead: UserDto — two transformers fit User → UserDto: withTransformer #1 and #3. Pass one.
A passed and a context transformer both fitInvoiceDto.total: MoneyDto — two transformers fit Money → MoneyDto: withTransformer #1 and context parameter 'money'. Pass one.
An enum mapping given a variableInto<Status, StatusDto> — withEnumEntryRenamed takes the entries themselves, like Status.ACTIVE, not a value that holds one.
One entry renamed twiceStatusDto — Status.ARCHIVED is renamed twice, by withEnumEntryRenamed #1 and #3. Keep one.
Two enum fallbacksStatusDto — StatusDto falls back twice, by withEnumFallback #2 and #4. Keep one.
A sealed rename given a variableInto<Shape, ShapeDto> — withSealedCaseRenamed takes class literals, like Shape.Hexagon::class, not a value that holds one.
A sealed fallback given a variableInto<Shape, ShapeDto> — withSealedFallback takes the object itself, like ShapeDto.Unsupported, not a value that holds one.
One case renamed twiceShapeDto — Shape.Hexagon is renamed twice, by withSealedCaseRenamed #1 and #2. Keep one.

Warnings

A mapping or transformer that fits nothing is a warning, not an error. It usually means a type it names has changed underneath it.

IdMessage
KIMNEY_UNUSED_TRANSFORMERwithTransformer(Int → Long) is not used: no pair below the root fits it. A type it names may have changed.
KIMNEY_UNUSED_ENUM_MAPPINGwithEnumEntryRenamed(Other.A → OtherDto.B) is not used: no Other → OtherDto pair occurs. A type it names may have changed.
KIMNEY_UNUSED_ENUM_MAPPINGwithEnumFallback(OtherDto.B) is not used: nothing becomes OtherDto. A type it names may have changed.
KIMNEY_UNUSED_SEALED_MAPPINGwithSealedCaseRenamed(ShapeDto.Circle → Shape.Circle) is not used: no sealed type with ShapeDto.Circle becomes one with Shape.Circle. A type it names may have changed.
KIMNEY_UNUSED_SEALED_MAPPINGwithSealedFallback(Other.Unknown) is not used: no sealed type becomes one with Other.Unknown. A type it names may have changed.

An enum fallback that has nothing to catch today is not a warning, because being written ahead of need is what it is for. A sealed fallback that is not one of the target's object cases fits nothing, so it does warn.

At runtime

A derived mapping has no runtime failures of its own beyond what a hand-written one has. Three things can still surface:

  • KimneyNotApplied, an IllegalStateException, if the code was compiled without the plugin. Its message reads transformInto reached runtime: the kimney compiler plugin did not replace this call. Apply it with plugins { id("io.github.matthewjones372.kimney") }.
  • NoWhenBranchMatchedException, if an enum entry or sealed case was added to a source compiled elsewhere after your call was compiled, and no fallback was given.
  • A stack overflow, for a value graph with a cycle.

If you ever see kimney's checker accepted a call its lowering cannot build. This is a bug in kimney. (KIMNEY_INTERNAL_ERROR), the two halves of the plugin disagreed. That is a bug to report, not something to fix in your code.

Recipes

Each recipe is a whole file from the kimney example/ project, compiled through the Gradle plugin and run by a test on every build, except the last two, which come from the petshop service.

Request, command, aggregate, event, row and view

One order through six shapes in five one-line crossings. Value-class identities in the domain, plain columns in the row, a view with an extra enum entry.

example/src/main/kotlin/example/cookbook/layers/OrderFlow.kt
package example.cookbook.layers

import io.github.matthewjones372.kimney.into
import io.github.matthewjones372.kimney.transformInto

// The domain: identities and quantities are types, not Longs and Ints.
@JvmInline value class OrderId(val value: Long)

@JvmInline value class CustomerId(val value: Long)

@JvmInline value class Sku(val code: String)

@JvmInline value class Quantity(val units: Int)

enum class OrderStatus { PLACED, SHIPPED }

data class OrderLine(val sku: Sku, val quantity: Quantity)

data class Order(val id: OrderId, val customerId: CustomerId, val lines: List<OrderLine>, val status: OrderStatus)

// The way in: what the API accepts, and the command it becomes.
data class LineRequest(val sku: String, val quantity: Int)

data class PlaceOrderRequest(val customerId: Long, val lines: List<LineRequest>)

data class PlaceOrder(val customerId: CustomerId, val lines: List<OrderLine>)

// The way out: what happened, what is stored, and what the API answers.
data class OrderPlaced(val orderId: OrderId, val customerId: CustomerId, val lines: List<OrderLine>)

enum class StatusColumn { PLACED, SHIPPED }

data class OrderRow(val id: Long, val customerId: Long, val status: StatusColumn)

enum class StatusView { PLACED, SHIPPED, UNKNOWN }

data class LineView(val sku: String, val quantity: Int)

data class OrderView(val id: Long, val lines: List<LineView>, val status: StatusView)

// Five crossings between six shapes, one line each.
fun PlaceOrderRequest.toCommand(): PlaceOrder = transformInto()

fun PlaceOrder.toOrder(id: OrderId): Order = into<_, Order>()
    .withFieldConst(Order::id, id)
    .withFieldConst(Order::status, OrderStatus.PLACED)
    .transform()

fun Order.placed(): OrderPlaced = into<_, OrderPlaced>().withFieldRenamed(Order::id, OrderPlaced::orderId).transform()

fun Order.toRow(): OrderRow = transformInto()

fun Order.toView(): OrderView = transformInto()

What each line does not say, Kimney derives: Long into CustomerId and back, String into Sku, a List<LineRequest> into a List<OrderLine>, OrderStatus into StatusColumn and StatusView. OrderRow has no lines, so they are left behind.

An event into a message, with cases the other side lacks

A rename derives a case into another case by every rule, a transformer from a case into the target's sealed parent builds it by hand, and a fallback object takes whatever is left, including cases added later.

example/src/main/kotlin/example/cookbook/cases/SealedCases.kt
package example.cookbook.cases

import io.github.matthewjones372.kimney.Transformer
import io.github.matthewjones372.kimney.into

sealed interface Event {
    data class Opened(val ticket: Long) : Event

    data class Escalated(val ticket: Long, val level: Int) : Event

    data class Merged(val ticket: Long, val into: Long) : Event

    data class Reacted(val ticket: Long, val emoji: String) : Event
}

sealed interface Message {
    data class Opened(val ticket: Long) : Message

    data class Raised(val ticket: Long, val level: Int) : Message

    data class Closed(val ticket: Long, val reason: String) : Message

    data object Ignored : Message
}

// Escalated is called Raised downstream; a merge closes the ticket; nothing else is sent on.
val merged = Transformer<Event.Merged, Message> { Message.Closed(it.ticket, "merged into ${it.into}") }

fun Event.toMessage(): Message = into<_, Message>()
    .withSealedCaseRenamed(Event.Escalated::class, Message.Raised::class)
    .withTransformer(merged)
    .withSealedFallback(Message.Ignored)
    .transform()

Opened matches by name, Escalated becomes Raised, Merged goes through merged, and Reacted falls back to Ignored.

A Set into a List

Kimney will not pick an order for you. Write the decision down with withFieldComputed:

example/src/main/kotlin/example/cookbook/crossing/CrossingKinds.kt
package example.cookbook.crossing

import io.github.matthewjones372.kimney.into

data class Article(val title: String, val tags: Set<String>)

data class ArticleDto(val title: String, val tags: List<String>)

fun Article.toDto(): ArticleDto = into<_, ArticleDto>()
    .withFieldComputed(ArticleDto::tags) { it.tags.sorted() }
    .transform()

A value-class entity to and from a row

The value classes file is the whole recipe: User.toRow() unwraps UserId to the Long column, and UserRow.toUser() wraps it back. Neither needs an override, even though the value class's property is called raw and the column id.

Money shown the same way across an API layer

Put the transformer in a context parameter once, and every mapping beneath it uses it. See Context transformers for the file, and remember this.transformInto().

Validating at the edge

Where anything may be missing, collect every error with its path instead of failing at the first.

example/src/main/kotlin/example/cookbook/partial/Validation.kt
package example.cookbook.partial

import io.github.matthewjones372.kimney.Partial
import io.github.matthewjones372.kimney.transformIntoPartial

@JvmInline
value class Email(val address: String) {
    init {
        require("@" in address) { "is not an email address" }
    }
}

// What arrives over the wire: anything may be missing.
data class SignupForm(val email: String?, val name: String?, val referrals: List<String?>)

// What the domain accepts: nothing is.
data class Signup(val email: Email, val name: String, val referrals: List<Email>)

fun SignupForm.validate(): Partial<Signup> = transformIntoPartial()

SignupForm("nope", null, listOf("a@b.c", null)).validate() gives:

Result
Errors([PartialError("Signup.email", "is not an email address"),
        PartialError("Signup.name", "is null"),
        PartialError("Signup.referrals[]", "is null")])

Nothing is built from a part that failed, so no constructor ever runs on a value Kimney made up. Only IllegalArgumentException is caught; anything else is a bug and is thrown.

Parsing at the edge

What Kimney cannot check itself (a date, a currency code) is a PartialTransformer, written once.

example/src/main/kotlin/example/cookbook/parsing/Parsing.kt
package example.cookbook.parsing

import io.github.matthewjones372.kimney.Partial
import io.github.matthewjones372.kimney.PartialError
import io.github.matthewjones372.kimney.PartialTransformer
import io.github.matthewjones372.kimney.into
import java.time.LocalDate
import java.time.format.DateTimeParseException

/** A parse that can fail, written once: its error has no path, so it lands wherever the date sits. */
val isoDate = PartialTransformer<String, LocalDate> { text ->
    try {
        Partial.Ok(LocalDate.parse(text))
    } catch (e: DateTimeParseException) {
        Partial.Errors(listOf(PartialError("", "is not a date: ${e.parsedString}")))
    }
}

data class StayForm(val checkIn: String, val checkOut: String)

data class Stay(val checkIn: LocalDate, val checkOut: LocalDate)

fun StayForm.toStay(): Partial<Stay> = into<_, Stay>().withPartialTransformer(isoDate).transformPartial()

StayForm("2026-09-24", "soon").toStay() gives Errors([PartialError("Stay.checkOut", "is not a date: soon")]). The transformer's error had an empty path, so it lands at the field.

Domain events to Avro wire records (petshop)

The petshop service publishes its domain events to Kafka as Avro. The wire contract is a separate set of types, kept apart from the domain so a change to either breaks the build rather than the topic. The domain side:

petshop domain (trimmed)
@JvmInline value class PetId(val value: Long)

enum class Species { Cat, Dog, Parrot, Tortoise }

sealed interface Standing
data object OnShelf : Standing
data class Adopting(val by: String) : Standing
data class Adopted(val by: String) : Standing

data class Pet(val id: PetId, val name: String, val species: Species, val standing: Standing = OnShelf)

sealed interface ShopEvent { val seq: Long }
data class PetArrived(override val seq: Long, val pet: Pet) : ShopEvent
data class PetAdopted(override val seq: Long, val pet: Pet, val by: String) : ShopEvent
data class PetReturned(override val seq: Long, val pet: Pet) : ShopEvent
petshop/app/wire/Wire.kt (annotations trimmed)
// The published Avro contract. kimney maps cases to the domain's by name, so names must match the domain's.
sealed interface WireEvent { val seq: Long }
data class PetArrived(override val seq: Long, val pet: Pet) : WireEvent
data class PetAdopted(override val seq: Long, val pet: Pet, val by: String) : WireEvent
data class PetReturned(override val seq: Long, val pet: Pet) : WireEvent

data class Pet(val id: Long, val name: String, val species: Species, val adopted: Boolean)

enum class Species { Cat, Dog, Parrot, Tortoise }

The mapping, both ways:

petshop/app/Avro.kt (mapping part)
// The wire's pet says only whether it is adopted; the event says by whom.
private val petOnTheWire = Transformer<Pet, WirePet> { pet ->
    pet.into<_, WirePet>().withFieldComputed(WirePet::adopted) { it.standing !is OnShelf }.transform()
}

fun ShopEvent.toWire(): WireEvent = into<_, WireEvent>().withTransformer(petOnTheWire).transform()

// By hand around the pet: its standing comes from the event, which the wire's pet cannot say.
fun WireEvent.toDomain(): ShopEvent = when (this) {
    is WirePetArrived -> PetArrived(seq, pet.toDomain(OnShelf))
    is WirePetAdopted -> PetAdopted(seq, pet.toDomain(Adopted(by)), by)
    is WirePetReturned -> PetReturned(seq, pet.toDomain(OnShelf))
}

private fun WirePet.toDomain(standing: Standing): Pet =
    into<_, Pet>().withFieldConst(Pet::standing, standing).transform()

What this shows:

  • toWire maps the sealed event case by case, by name. Inside the pet, PetId unwraps to the wire's Long and Species maps entry by entry.
  • The wire pet's adopted has no source property, and an override cannot reach a nested field. So the nested Pet → WirePet pair gets a transformer, itself written with a Kimney chain.
  • The way back is partly by hand on purpose: a wire pet cannot say who adopted it, so the event decides its standing and withFieldConst supplies it. Long wraps back into PetId on its own.
  • Adding a domain event without a wire case of the same name stops the build at toWire.

Domain errors into API problems (petshop)

The same service turns its sealed PetShopError into an API ProblemDto with one line. Each domain error has a message with a default; each problem case requires one, and Kimney reads it from the source.

petshop/api/Dtos.kt (excerpt)
/** A domain failure added without a case of the same name here stops the build at this call. */
fun PetShopError.toDto(): ProblemDto = transformInto()

/** A pet being adopted reads as taken: nobody browsing needs to know a transfer is in flight. */
fun Pet.toDto(): PetDto = into<_, PetDto>().withFieldComputed(PetDto::adopted) { it.standing !is OnShelf }.transform()

Costs

What it generates

A call compiles to the constructor calls, null checks, whens and loops you would have written. No reflection, no mapping registry, no generated mapper classes. The source is evaluated once. A withFieldComputed lambda becomes a direct call of its body, and the tests check that no lambda object is created where a hand-written mapping would not create one. Recursion becomes a local function inside the call.

Measured at runtime

The kimney repository benchmarks five mappings, each written once with Kimney and once by hand, with a test that both give equal results. JMH 1.37, average time, two forks, -prof gc for allocation, on an Apple M3 with Temurin 21.0.9:

PairBy hand (ns/op)Kimney (ns/op)By hand (B/op)Kimney (B/op)
orderView: ten lines, value classes unwrapped, an enum45.6 ± 0.543.7 ± 0.4352352
placeOrder: ten lines, value classes wrapped45.9 ± 16.846.0 ± 5.9344344
customer: two nested classes, one nullable, a default8.9 ± 3.58.0 ± 0.49696
signupValid: transformIntoPartial, form passes45.4 ± 0.843.7 ± 0.9280280
signupInvalid: the same form failing2846.9 ± 32.52852.5 ± 26.440804136

Total mappings are the same code: time agrees within the error, and allocation agrees to the byte. A failing validation is dominated by the exceptions both sides build; Kimney allocates 56 bytes more per failing call there (about 1.4%), most likely from the lowering's intermediate list. These are microbenchmarks on one machine; they say nothing about how much of a service's time mapping takes.

Run the benchmarks yourself, in the kimney checkout (about three and a half minutes)
./gradlew :benchmarks:jmh

At build time

The docs give no measurement of build-time cost. What they do say:

  • Errors come from the K2 frontend checker, before any code is generated.
  • Since 0.3.0, mappings recompile under Kotlin incremental compilation when a type they read changes, including types in another module. Before that, adding an enum entry in one module could leave a mapping in another compiling green until a clean build.
  • From 0.4.0 (unreleased) the Gradle plugin is declared and tested compatible with the configuration cache.

Gotchas and limits

Gotchas

  • Defaults are used silently. A target field with a default and no source of that name takes the default without a word. This is on purpose: a default is how a type says what it wants when nothing is given. A target field whose default should never be used in a mapping should not have one.
  • Errors appear on build, not in the editor. See IDE support.
  • The chain must be one expression. No stored builders, no helper extensions in the middle.
  • this.transformInto() under a context parameter. A bare call is ambiguous there.
  • A value class source into a data class unwraps first. It does not match the value class's property name.
  • No numeric widening. Int into Long needs a transformer or an override.
  • Fallbacks absorb what you forget. Prefer renames for enums and sealed types you own.
  • A partial transformer in a total call is an error; end the chain with .transformPartial().

Not supported yet

  • Overrides on nested fields. A transformer for the nested pair covers them.
  • Concrete collection targets such as ArrayList and HashMap.
  • Primitive arrays, other than as themselves.
  • Crossing collection kinds (Set into List), by design.
  • Targets without a public primary constructor, and standard library classes as constructor targets.
  • Sealed cases whose type parameter appears only inside another type.
  • Non-JVM targets.

Status

Kimney is 0.x. The latest release is 0.3.0, on Maven Central; the next, 0.4.0, is in progress. Its surface may still change between releases. Each change is built from a spec in the repository's specs/ directory.

Roadmap highlights

SpecWhat it delivered
0002 to 0005Class to class, the override DSL, enums and sealed types by name, nullables and value classes
0006, 0011Your own transformers, passed or from context parameters
0008, 0013Partial transformations and failable transformers
0010, 0017Collections, maps, arrays, then mutable collections
0016Generic sealed types and value classes
0018What it costs, measured
0019Every Kotlin 2.4, tested on each
0020Correct incremental compilation
0021, 0022Enum entries and sealed cases renamed, with fallbacks
0023The configuration cache

Overrides on nested fields are the main gap the docs name as not yet done.

Where next