Compile-time object mapping for Kotlin, checked in the IDE as you type.
Find a file
NillPoe daccfd452a
All checks were successful
Publish Package (decant) TeamCity build finished
Release 1.0.0-2.4.10
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:02:22 +09:00
decant-api Initial Decant implementation 2026-09-29 14:17:26 +09:00
decant-compiler-plugin Prefer the receiver as the main source 2026-09-29 14:52:10 +09:00
decant-gradle-plugin Initial Decant implementation 2026-09-29 14:17:26 +09:00
gradle Initial Decant implementation 2026-09-29 14:17:26 +09:00
samples/ide-check Release 1.0.0-2.4.10 2026-09-29 15:02:22 +09:00
.gitattributes Initial Decant implementation 2026-09-29 14:17:26 +09:00
.gitignore Initial Decant implementation 2026-09-29 14:17:26 +09:00
build.gradle.kts Initial Decant implementation 2026-09-29 14:17:26 +09:00
gradle.properties Release 1.0.0-2.4.10 2026-09-29 15:02:22 +09:00
gradlew Initial Decant implementation 2026-09-29 14:17:26 +09:00
gradlew.bat Initial Decant implementation 2026-09-29 14:17:26 +09:00
LICENSE Initial Decant implementation 2026-09-29 14:17:26 +09:00
README.md Release 1.0.0-2.4.10 2026-09-29 15:02:22 +09:00
settings.gradle.kts Initial Decant implementation 2026-09-29 14:17:26 +09:00

Decant

Decant is a Kotlin compiler plugin that writes object mappers. Assign the values that differ with plain Kotlin, and Decant fills in the rest from properties with the same name, converting them where needed. Every mapping error shows up in the editor while you type, not only when you build.

@Mapper
fun Playlist.toDto(): PlaylistDto = mapping {
    to.isPublic = visibility == Visibility.PUBLIC
    to.owner = owner ?: "unknown"
    // title, items, cover, ...: read from `Playlist` and converted by other mappers
}

The compiler replaces mapping { } with a direct constructor call. There is no reflection, no generated source file and no lambda at run time.

Decant takes the idea of Mappie and moves all checks into the compiler's analysis phase, the same phase IntelliJ IDEA runs, so the IDE reports what the build reports.

Contents

Setup

Decant publishes to the Forgejo package registry at https://forgejo.verchase.com/api/packages/nillpoe/maven. Add it to your repositories:

// settings.gradle.kts
pluginManagement {
    repositories {
        maven("https://forgejo.verchase.com/api/packages/nillpoe/maven")
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositories {
        maven("https://forgejo.verchase.com/api/packages/nillpoe/maven")
        mavenCentral()
    }
}

Then apply the plugin next to the Kotlin plugin:

// build.gradle.kts
plugins {
    kotlin("jvm") version "2.4.10" // or kotlin("multiplatform")
    id("xyz.nillpoe.decant") version "1.0.0-2.4.10"
}

The plugin adds the xyz.nillpoe.decant:decant-api library to implementation (or commonMainImplementation in a multiplatform project). The library supports every Kotlin target: JVM, Android, JS, Wasm and Kotlin/Native.

To try a local build instead, run ./gradlew publishToMavenLocal in this repository and add mavenLocal() to both repository blocks.

Writing mappers

A mapper is a function whose body is mapping. Its sources are the function's extension receiver and parameters, and its target is the return type, which you must write out.

// An extension function: the receiver is the source.
@Mapper
fun Playlist.toDto(): PlaylistDto = mapping()

// A plain function works the same way.
@Mapper
fun toDto(playlist: Playlist): PlaylistDto = mapping()

// Several sources: every parameter is a source.
fun EmailMessage.toRequest(attachments: List<EmailAttachment>): SendRequest = mapping {
    to.attachments = attachments.takeIf { it.isNotEmpty() }?.map { it.toRequestAttachment() }
}

// A mapper with dependencies lives in a class.
@Component
class MediaMappers(private val signer: UrlSigner) {
    @Mapper
    fun Image.toDto(): ImageDto = mapping {
        to.url = signer.sign(path)
    }
}

@Mapper lets other mappings use the function to convert values implicitly. It needs exactly one source: an extension receiver or a single parameter. A function that is not a mapping can be a mapper too:

@Mapper
fun Duration.toMinutes(): Long = inWholeMinutes

On an object, @Mapper makes every single-source function in it a mapper. In a class, a mapper can only be used by other functions of the same class, which have the instance to call it on.

There are no mapList helpers. Call the mapper yourself (playlists.map { it.toDto() }); inside a mapping, Decant converts lists and sets for you.

The mapping block

Inside mapping { }, write to.property = value. The value is any Kotlin expression. Kotlin checks it against the property's exact type, so type inference works as usual:

@Mapper
fun Video.toDto(): VideoDto {
    val base = title.lowercase()
    return mapping {
        val slug = base.replace(' ', '-')       // local values are fine
        to.slug = slug
        to.type = MediaType.HLS
        to.tags = emptyList()                   // inferred as List<String>
        to.note = null                          // nullable properties take null
        to.version = default()                  // the constructor parameter's default value
        to.cover = convert(image)               // Decant finds the conversion
        to.stats = mapping {                    // builds a nested object from the same sources
            to.ratio = likes.toDouble() / views
        }
    }
}

Every constructor parameter you do not assign takes, in order:

  1. the source parameter with the same name,
  2. or the source property with the same name, converted if needed,
  3. or its default value.

If none of them applies, or two sources have a property with that name, the mapping does not compile.

Rules:

  • The block runs from top to bottom before the constructor is called. Assign each property once, at the top level of the block. For a conditional value, assign an expression: to.x = if (a) b else c.
  • to only stands for the target. Reading to.x is an error.
  • Decant picks the primary constructor, or the only public constructor.

Conversions

Implicit mappings and convert(value) use the same rules, in this order. When two candidates tie at one step, the mapping does not compile and the error lists them.

  1. The types already fit. The value is used as it is.
  2. A @Mapper. Among the mappers that accept the value, the one for the most specific type wins, like overload resolution: a VideoMedia.Progressive goes to the Progressive mapper, not the VideoMedia one. Mappers of the enclosing class win over others.
  3. Value classes. A value class with one property wraps and unwraps its underlying type (AccountId and Long).
  4. Enums and sealed types. See below.
  5. Lossless number widening. Byte, Short and Int to Long; Int and Float to Double.
  6. Collections. List, Set, Collection and Iterable convert element by element. T? converts to R? and keeps null.

Decant never converts implicitly where a value could change or be lost: toString(), parsing strings, Long to Int, nullable to non-null, java.time and Duration. Write those yourself, or declare a @Mapper once and use it everywhere in the module.

Java classes work as sources: getName() reads as name, isActive() as isActive, and public fields are read directly.

Enums and sealed types

mapping() converts an enum or a sealed value as a whole when the target is an enum, a sealed type or an abstract type:

  • enum entries and object subtypes go to the target entry with the same name;
  • other subtypes of a sealed type go through their own mapper.

Handle exceptions with Kotlin's when, and let else -> mapping() do the rest. Decant only checks the cases the earlier branches did not handle.

@Mapper
fun Status.toDto(): StatusDto = when (this) {
    Status.BANNED -> StatusDto.HIDDEN
    else -> mapping()
}

// Replaces a hand-written `when` over every subtype.
@Mapper
fun Video.toDto(): VideoDto = mapping()

If an entry has no counterpart, or a subtype has no mapper, the mapping does not compile. When you add a subtype and forget its DTO, the build stops.

A property of a sealed type converts the same way, even without a mapper for the sealed type itself: media: VideoMedia becomes a VideoPreviewDto through the mappers of Progressive and Hls.

Builder targets (protobuf)

Some types are built with Target { this.x = ... } instead of a constructor. kotlinx-rpc protobuf messages look like this: an interface with a nested Builder whose properties are vars, and a companion invoke(Builder.() -> Unit). Decant builds such targets through the builder:

@Mapper
fun Authorization.toProto(): AuthorizationItem = mapping()

@Mapper
fun AuthorizationItem.toDomain(): Authorization = mapping()
  • A builder property without a source keeps the builder's default. It is not an error.
  • A null value leaves the property unset, like an optional protobuf field. to.note = note?.uppercase() is allowed even though note is not nullable in the message.
  • Protobuf enums, which kotlinx-rpc generates as sealed classes of objects plus UNRECOGNIZED(number), map by entry name. Handle UNRECOGNIZED in an earlier when branch:
@Mapper
fun CrawlCapabilityProto.toDomain(): CrawlCapability = when (this) {
    is CrawlCapabilityProto.UNRECOGNIZED -> throw IllegalArgumentException("Unknown crawl capability: $number")
    else -> mapping()
}

Mappers from other modules

Decant finds the @Mapper declarations of the module it compiles on its own. Mappers of other modules come through @UseMappers, which names objects:

// In the shared module
@Mapper
object MediaMappers {
    fun Image.toDto(): ImageDto = mapping()
}

// In the app module, on the file, a class or a function
@file:UseMappers(MediaMappers::class)

In an object that is not @Mapper itself, only its functions marked @Mapper count. Top-level mapper functions of another module cannot be named in an annotation; call them yourself, or group them in an object.

The rule is the same in the IDE and in the build, so a mapping never compiles in one and fails in the other.

Compiler errors

Every error says what is wrong and how to fix it. Some examples:

e: `PlaylistDto.ownerId` (AccountId) has no source.
   `Playlist` has no property `ownerId`. Similar: `ownerAccountId` (AccountId), `owner` (Account).
   Assign it: to.ownerId = ownerAccountId

e: `ProfileDto.count` cannot be set from property `Profile.count` of type `Long`: the parameter is `Int`,
   and there is no conversion because no `@Mapper` takes `Long` and returns `Int` (mappers from other
   modules need `@UseMappers`). Assign it explicitly (`to.count = ...`), or declare a `@Mapper`.

e: Decant cannot turn `OutcomeProto` into `Outcome`: these subtypes of `OutcomeProto` cannot be converted:
   `UNRECOGNIZED` (it is not an object, so handle it in an earlier `when` branch, or declare a `@Mapper` for it).
Error When
DECANT_NO_SOURCE A target has no source and no default value
DECANT_AMBIGUOUS_SOURCE Two sources have a value with the target's name
DECANT_TYPE_MISMATCH A source was found, but nothing converts it
DECANT_AMBIGUOUS_MAPPER Two mappers could convert a value
DECANT_NO_CONVERSION convert(value), an enum or a sealed value cannot be converted
DECANT_VALUE_TYPE_MISMATCH to.x = value with a value of the wrong type
DECANT_NOT_A_PARAMETER to.x is not a constructor parameter or builder property
DECANT_DUPLICATE_TARGET A target is assigned twice
DECANT_NO_DEFAULT to.x = default() for a parameter without a default
DECANT_NO_CONSTRUCTOR The target has no usable constructor
DECANT_NO_SOURCES mapping is not inside a function with sources, or is inside a lambda
DECANT_UNSUPPORTED_STATEMENT The block holds something other than assignments and local values
DECANT_TARGET_READ to.x is read
DECANT_INVALID_USED_MAPPERS @UseMappers names a class or an object without mappers

Configuration

decant {
    enabled = true           // runs the compiler plugin
    addApiDependency = true  // adds decant-api to the dependencies
}

IDE support

Decant resolves mappings in the compiler's analysis phase, which IntelliJ IDEA also runs, so the editor shows Decant's errors while you type. The IDE must load the plugin, because to.x = value on a val is not valid Kotlin without it.

IntelliJ IDEA only loads compiler plugins it bundles, unless you allow others:

  1. Use K2 mode in the Kotlin plugin settings.
  2. Open Help | Find Action, run Registry..., and set kotlin.k2.only.bundled.compiler.plugins.enabled to false.
  3. Restart the IDE.

After updating Decant, reload the Gradle project so that the IDE picks up the new plugin.

The IDE does not know that Decant reads source properties and calls mappers, so it may mark them as unused. For mapper functions, add xyz.nillpoe.decant.Mapper under Settings | Editor | Inspections | Kotlin | Redundant constructs | Unused symbol | Annotations.

samples/ide-check is a small two-module project to try this: Valid.kt has no errors, and every mapping in Broken.kt is marked. Run ./gradlew publishAllPublicationsToTestingRepository in this repository first, then open the sample folder as a project.

Compatibility

Kotlin compiler plugins use unstable compiler APIs, so each Decant release works with one Kotlin version. The version name ends with that Kotlin version: 1.0.0-2.4.10 is Decant 1.0.0 for Kotlin 2.4.10.

Kotlin Decant
2.4.10 1.0.0-2.4.10

Kotlin 2.4.10 turns off context-sensitive resolution for expected types by default, so to.type = HLS needs -Xcontext-sensitive-resolution. Without it, write to.type = MediaType.HLS.

Coming from Mappie

Mappie Decant
object AToB : ObjectMappie<A, B>() @Mapper then fun A.toB(): B = mapping()
override fun map(from: A) = mapping { } fun A.toB(): B = mapping { }
ObjectMappie2<A, B, C> fun A.toC(b: B): C = mapping()
EnumMappie<A, B> mapping(), with when for exceptions
to::x fromProperty from::y to.x = y
to::x fromValue v, fromExpression { } to.x = v
to::x fromPropertyNotNull from::y to.x = y!!
to::x fromProperty from::y transform { M.mapList(it) } nothing if the names match, else to.x = convert(y)
to::x fromProperty from::y via M nothing if the names match, else to.x = convert(y)
AToB.map(a), AToB.mapList(list) a.toB(), list.map { it.toB() }
@UseDefaultArguments defaults are used unless a source exists; to.x = default() forces one
Hand-written sealed dispatch mapping()

Building

./gradlew build
Module Contents
decant-api mapping, @Mapper, @UseMappers, for every Kotlin target
decant-compiler-plugin FIR: assignment rewriting, resolution and errors. IR: code generation from the FIR plan
decant-gradle-plugin The xyz.nillpoe.decant Gradle plugin

FIR decides everything and stores a plan per mapping call; IR only turns the plan into code. That way the IDE, which only runs FIR, reports every error the build can hit.

The compiler tests use the Kotlin compiler's own test framework. Each file in decant-compiler-plugin/testData becomes a test:

  • box/: compiles and runs fun box(), which returns "OK" on success. Files can hold several modules and Java sources.
  • diagnostic/: compares the reported errors with the inline markers and the .fir.diag.txt file.

After changing a message or adding a test, run ./gradlew :decant-compiler-plugin:test -Poverwrite to rewrite the expected files, then review the diff.

The Gradle plugin tests publish all modules to build/testing-repository and build sample projects with Gradle TestKit.

Publishing

The build publishes to the Forgejo package registry with basic authentication. It reads the credentials from two Gradle properties, forgejoUsername and forgejoPassword. For the password, create an access token in Forgejo under Settings | Applications with the write:package scope.

Keep the credentials out of this repository. Put them in your user-level Gradle properties file: ~/.gradle/gradle.properties on macOS and Linux, %USERPROFILE%\.gradle\gradle.properties on Windows.

forgejoUsername=<your Forgejo user name>
forgejoPassword=<access token>

Then publish:

./gradlew publishAllPublicationsToForgejoRepository

License

Apache License 2.0. See LICENSE.