A Kotlin KSP plugin for generating structural mappers between classes that share the same structure.
If it quacks the same, it should map automatically.
When you have separate models for different layers (API DTOs, database entities, domain models, UI models), you end up writing tedious mapping code:
fun UserDto.toUser() = User(
id = this.id,
name = this.name,
email = this.email
)This is boilerplate. DuckMapper generates these mappers at compile time.
Add Jitpack repository to your settings.gradle.kts:
dependencyResolutionManagement {
repositories {
maven { url = uri("https://jitpack.io") }
}
}Add dependencies to your module's build.gradle.kts:
plugins {
id("com.google.devtools.ksp")
}
dependencies {
implementation("com.github.lelloman.duckmapper:annotations:0.1.0")
ksp("com.github.lelloman.duckmapper:ksp:0.1.0")
}Declare your mapping:
@DuckMap(UserDto::class, User::class)
object MappingsDuckMapper generates bidirectional extension functions:
// Generated
fun UserDto.toUser(): User = User(
id = this.id,
name = this.name,
email = this.email
)
fun User.toUserDto(): UserDto = UserDto(
id = this.id,
name = this.name,
email = this.email
)Stack multiple annotations on a single object:
@DuckMap(UserDto::class, User::class)
@DuckMap(AddressDto::class, Address::class)
@DuckMap(OrderEntity::class, Order::class)
object MappingsWhen classes contain nested types that also need mapping, declare mappings for both:
// Domain module
data class DomainUser(val id: String, val address: DomainAddress)
data class DomainAddress(val street: String, val city: String)
// UI module
data class UiUser(val id: String, val address: UiAddress)
data class UiAddress(val street: String, val city: String)
// App module - declare BOTH mappings
@DuckMap(DomainAddress::class, UiAddress::class) // Required for nested type
@DuckMap(DomainUser::class, UiUser::class)
object MappingsGenerated code automatically uses the nested mapper:
fun DomainUser.toUiUser(): UiUser = UiUser(
id = this.id,
address = this.address.toUiAddress() // Uses generated mapper
)DuckMapper generates exhaustive when expressions for enum mapping:
// domain
enum class DomainStatus { PENDING, ACTIVE }
// ui
enum class UiStatus { PENDING, ACTIVE, INACTIVE }
// app - explicit mapping required
@DuckMap(DomainStatus::class, UiStatus::class)
object MappingsGenerated code:
fun DomainStatus.toUiStatus(): UiStatus = when (this) {
DomainStatus.PENDING -> UiStatus.PENDING
DomainStatus.ACTIVE -> UiStatus.ACTIVE
}Validation rules:
- ✅ Subset → Superset: OK (all source values exist in target)
- ❌ Superset → Subset: ERROR (source has values not in target)
DuckMapper supports List, Array, and Map with automatic element mapping:
data class DomainTeam(val members: List<DomainUser>)
data class UiTeam(val members: List<UiUser>)
// With @DuckMap(DomainUser::class, UiUser::class) declared:
fun DomainTeam.toUiTeam(): UiTeam = UiTeam(
members = this.members.map { it.toUiUser() }
)For Map, both keys and values can be mapped:
data class DomainCache(val entries: Map<DomainId, DomainUser>)
data class UiCache(val entries: Map<UiId, UiUser>)
// With mappings declared for DomainId/UiId and DomainUser/UiUser:
fun DomainCache.toUiCache(): UiCache = UiCache(
entries = this.entries.map { (k, v) -> k.toUiId() to v.toUiUser() }.toMap()
)DuckMapper handles nullable type conversions:
- ✅
String→String?(downcast allowed) - ❌
String?→String(compile error - unsafe)
data class DomainUser(val nickname: String) // non-nullable
data class UiUser(val nickname: String?) // nullable - OK
// Works: non-nullable can map to nullable
fun DomainUser.toUiUser(): UiUser = UiUser(nickname = this.nickname)
// Reverse mapping will fail at compile time with clear error messageA class with more properties can map to a class with fewer properties:
data class DomainUser(
val id: String,
val name: String,
val internalFlag: Boolean // Extra property
)
data class UiUser(
val id: String,
val name: String
// No internalFlag - that's fine
)
// Works: DomainUser → UiUser (extra properties ignored)
// Fails: UiUser → DomainUser (missing required property)@DuckWrap generates a wrapper class using Kotlin's delegation (by keyword). The source class must implement the target interface:
interface Displayable {
val title: String
val description: String
}
data class Article(
override val title: String,
override val description: String,
val internalId: String // Hidden from interface
) : Displayable
@DuckWrap(Article::class, Displayable::class)
object MappingsGenerated code:
internal class DuckWrapDisplayable(
private val wrapped: Article
) : Displayable by wrapped
fun Article.asDisplayable(): Displayable = DuckWrapDisplayable(this)Use case: Hide implementation details while exposing only the interface. The wrapper maintains a live reference to the source.
@DuckImplement generates an implementation class that copies property values. The source doesn't need to implement the interface - it just needs matching properties:
interface UiDisplayable {
val title: String
val description: String
}
// Note: Does NOT implement UiDisplayable
data class ApiResponse(
val title: String,
val description: String,
val metadata: String
)
@DuckImplement(ApiResponse::class, UiDisplayable::class)
object MappingsGenerated code:
internal class DuckImplUiDisplayable(
source: ApiResponse
) : UiDisplayable {
override val title: String = source.title
override val description: String = source.description
}
fun ApiResponse.toUiDisplayable(): UiDisplayable = DuckImplUiDisplayable(this)Use case: Create an interface implementation from any class with matching properties. Values are copied at construction time (snapshot, not live reference).
Key differences:
| Feature | @DuckWrap | @DuckImplement |
|---|---|---|
| Source requirement | Must implement target interface | Just needs matching properties |
| Extension function | as<Target>() |
to<Target>() |
| Value behavior | Live delegation | Snapshot copy |
| Generated class | Uses by delegation |
Explicit property overrides |
DuckMapper provides clear compile-time errors:
Missing property:
Cannot map UiUser to DomainUser: missing property 'internalFlag' in source class
Type mismatch without mapping:
Property 'address' type mismatch: DomainAddress -> UiAddress.
No @DuckMap declaration found for these types.
Nullable to non-nullable:
Property 'nickname': cannot map nullable type to non-nullable type (kotlin.String? -> kotlin.String)
// Data layer - API response
data class UserDto(
val id: String,
val name: String,
val email: String
)
// Domain layer
data class User(
val id: String,
val name: String,
val email: String
)
// Declare mapping
@DuckMap(UserDto::class, User::class)
object Mappings
// Usage in repository
class UserRepository(private val api: UserApi) {
suspend fun getUser(id: String): User {
return api.fetchUser(id).toUser() // Generated extension function
}
}- Kotlin 1.9+
- KSP 1.9+
Apache 2.0