The Kotlin Object Multiplatform Mapper provides you a possibility to generate (via KSP) extension function to map one object to another.
- Supports KSP Multiplatform
- Maps as constructor parameters as well as public properties with setter
- Supports properties' types cast
- Supports Java objects get* functions
- Supports multi-source classes with separated configurations
- Has next properties annotations:
- Specify mapping from property with different name
- Specify a converter to map data from source unusual way
- Specify a top-level extension function for property casting
- Specify a resolver to map default values into properties
- Specify target property default values from class-level annotations
- Specify null substitute to map nullable properties into not-nullable
- Support extension via plugins
- JVM
- JavaScript
- Linux
- Windows (mingwX64)
- macOS
- iOS
- Iterable Plugin:
- Support collections mapping with different types of elements
- Exposed Plugin:
- Support mapping from Exposed Table Object (ResultRow)
- Enum Plugin:
- Support Enum mapping with default value annotation
Add KOMM annotations and the KSP processor to the project where mapper functions should be generated.
Use this setup for a single-target JVM project that runs KOMM through KSP.
plugins {
id("com.google.devtools.ksp") version "2.3.9"
}
val kommVersion = "0.80.3"
dependencies {
implementation("com.ucasoft.komm:komm-annotations:$kommVersion")
ksp("com.ucasoft.komm:komm-processor:$kommVersion")
}Use this setup for Kotlin Multiplatform projects, adding the KOMM processor to every KSP target you generate for.
plugins {
id("com.google.devtools.ksp") version "2.3.9"
}
val kommVersion = "0.80.3"
kotlin {
jvm {
withJava()
}
js(IR) {
nodejs()
}
// Add other platforms
sourceSets {
val commonMain by getting {
dependencies {
implementation("com.ucasoft.komm:komm-annotations:$kommVersion")
}
}
val jvmMain by getting
val jsMain by getting
// Init other sourceSets
}
}
dependencies {
add("kspJvm", "com.ucasoft.komm:komm-processor:$kommVersion")
add("kspJs", "com.ucasoft.komm:komm-processor:$kommVersion")
// Add other platforms like `kspAndroidNativeX64`, `kspLinuxX64`, `kspMingwX64` etc.
}Use simple mapping when source and destination properties can be matched by name and converted automatically.
Declare the source and destination models, then annotate either side to request a mapper between them.
class SourceObject {
val id = 150
val intToString = 300
val stringToInt = "250"
}
@KOMMMap(from = [SourceObject::class])
data class DestinationObject(
val id: Int,
val stringToInt: Int
) {
var intToString: String = ""
}or
@KOMMMap(to = [DestinationObject::class])
class SourceObject {
val id = 150
val intToString = 300
val stringToInt = "250"
}
data class DestinationObject(
val id: Int,
val stringToInt: Int
) {
var intToString: String = ""
}KOMM generates an extension function that copies matching properties and casts compatible values.
fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
id = id,
stringToInt = stringToInt.toInt()
).also {
it.intToString = intToString.toString()
}Use mapping context when destination members depend on data that is not part of the source object, such as lookup tables produced by other flows.
Define a context object to carry extra data that is needed while building the destination model.
data class TransactionMapContext(
val accounts: Map<Long, Account>,
val accountCurrencies: Map<Long, AccountCurrency>,
val categories: Map<Long, Category>
)Attach the context type to the mapping so the generated function requires that context argument.
@KOMMMap(from = [DbTransaction::class], context = TransactionMapContext::class)
data class Transaction(
//...
)The generated mapper receives the context as a kommContext parameter.
fun DbTransaction.toTransaction(kommContext: TransactionMapContext): Transaction = Transaction(
//...
)The context is a snapshot. Combine reactive inputs before mapping, then build a fresh context whenever any dependency emits.
combine(transactions, accountCurrencies, categories, accounts) { items, currencies, cats, accs ->
val context = TransactionMapContext(accs, currencies, cats)
items.map { it.toTransaction(context) }
}Use mapping configuration to tune generated function names, automatic casts, and context handling.
Set tryAutoCast = false when incompatible property types should fail generation unless a converter is provided.
@KOMMMap(
from = [SourceObject::class],
config = MapConfiguration(
tryAutoCast = false
)
)
data class DestinationObject(
val id: Int,
val stringToInt: Int
) {
var intToString: String = ""
}e: [ksp] com.ucasoft.komm.processor.exceptions.KOMMCastException: AutoCast is turned off! You have to use @MapConvert annotation to cast (stringToInt: Int) from (stringToInt: String)Set convertFunctionName when the generated extension should use a custom function name.
@KOMMMap(
from = [SourceObject::class],
config = MapConfiguration(
convertFunctionName = "convertToDestination"
)
)
data class DestinationObject(
val id: Int,
val stringToInt: Int
) {
var intToString: String = ""
}fun SourceObject.convertToDestination(): DestinationObject = DestinationObject(
id = id,
stringToInt = stringToInt.toInt()
).also {
it.intToString = intToString.toString()
}Set nullableContext = true when a mapping context should be optional at the mapping function boundary.
KOMM generates a nullable context parameter with a default null value.
If a context-aware converter or resolver is used, the generated mapper checks that the context was provided before calling it.
@KOMMMap(
from = [SourceObject::class],
context = SourceMapContext::class,
config = MapConfiguration(
nullableContext = true
)
)
data class DestinationObject(
val id: Int
)fun SourceObject.toDestinationObject(kommContext: SourceMapContext? = null): DestinationObject = DestinationObject(
id = id
)Use @MapFunction when the automatic toType() cast should call a top-level extension function from another package.
KOMM imports the function and keeps extension-call syntax in generated code.
Declare the extension function that KOMM should call for the custom property conversion.
fun ByteArray.toImageBitmap(): ImageBitmap = //...Annotate the target property with the package and optional name of the conversion function.
data class SourceObject(
val logo: ByteArray?
)
@KOMMMap(from = [SourceObject::class])
data class DestinationObject(
@MapFunction(packageName = "com.test.converters")
val logo: ImageBitmap?
)or specify the function name explicitly:
@MapFunction(
packageName = "com.test.converters",
name = "toImageBitmap"
)The generated mapper imports and calls the configured extension function.
import com.test.converters.toImageBitmap
fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
logo = logo?.toImageBitmap()
)Use @MapName to connect properties whose names differ between the source and destination models.
Use @MapName when a source and destination property represent the same value with different names.
class SourceObject {
//...
val userName = "user"
}
@KOMMMap(from = [SourceObject::class])
data class DestinationObject(
//...
@MapName("userName")
val name: String
) {
var intToString: String = ""
}or
@KOMMMap(to = [DestinationObject::class])
class SourceObject {
//...
@MapName("name")
val userName = "user"
}
data class DestinationObject(
//...
val name: String
) {
var intToString: String = ""
}The generated mapper reads from the configured source property name.
fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
//...
name = userName
).also {
it.intToString = intToString.toString()
}Use @MapEmbedded when several destination properties should be mapped from the same nested source property.
KOMM checks only the first nested level. Direct source properties have priority over embedded properties.
If two embedded properties can provide the same destination property, generation fails and the mapping should be made explicit.
Mark the nested source property that should contribute values to the destination model.
data class Account(
val id: Long,
val name: String
)
data class AccountWithCurrencies(
val account: Account,
val currencies: List<AccountCurrency>
)
@KOMMMap(from = [AccountWithCurrencies::class])
@MapEmbedded("account")
data class AccountDto(
val name: String,
val currencies: List<AccountCurrencyDto>
) {
var id: Long = 0L
}The generated mapper reads destination values from both direct and embedded source properties.
fun AccountWithCurrencies.toAccountDto(): AccountDto = AccountDto(
name = account.name,
currencies = currencies.map { it.toAccountCurrencyDto() }
).also {
it.id = account.id
}When the embedded source is nullable, use defaults or substitutes for destination properties that cannot be null.
data class AccountWithCurrencies(
val account: Account?,
val currencies: List<AccountCurrency>
)
@KOMMMap(from = [AccountWithCurrencies::class])
@MapEmbedded("account")
data class AccountDto(
@NullSubstitute(MapDefault(StringResolver::class))
val name: String,
val currencies: List<AccountCurrencyDto>
)The generated mapper safely accesses the nullable embedded property and falls back to the configured default.
fun AccountWithCurrencies.toAccountDto(): AccountDto = AccountDto(
name = account?.name ?: StringResolver(null).resolve(),
currencies = currencies.map { it.toAccountCurrencyDto() }
)Use converters for property mapping that needs custom transformation logic.
Create a converter when a property needs custom logic that depends on the source object.
class CostConverter(source: SourceObject) : KOMMConverter<SourceObject, Double, DestinationObject, String>(source) {
override fun convert(sourceMember: Double) = "$sourceMember ${source.currency}"
}Apply @MapConvert to constructor or mutable properties that should use the converter.
class SourceObject {
//...
val cost = 499.99
}
@KOMMMap(from = [SourceObject::class])
data class DestinationObject(
//...
@MapConvert<SourceObject, DestinationObject, CostConverter>(CostConverter::class)
val cost: String
) {
//...
@MapConvert<SourceObject, DestinationObject, CostConverter>(CostConverter::class, "cost")
var otherCost: String = ""
}The generated mapper instantiates the converter and calls it for each annotated property.
fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
//...
cost = CostConverter(this).convert(cost)
).also {
//...
it.otherCost = CostConverter(this).convert(cost)
}@MapConvert can also use a context-aware converter when the mapping has KOMMMap.context.
data class AccountMapContext(
val banks: Map<Long, Bank>
)
class BankConverter(
source: FullAccount,
context: AccountMapContext
) : KOMMContextConverter<FullAccount, Long?, AccountMapContext, Account, Bank?>(source, context) {
override fun convert(sourceMember: Long?): Bank? =
sourceMember?.let(context.banks::get)
}Use a context-aware converter when the conversion also needs values from KOMMMap.context.
@KOMMMap(from = [FullAccount::class], context = AccountMapContext::class)
data class Account(
//...
@MapConvert<FullAccount, Account, BankConverter>(BankConverter::class, "bankId")
val bank: Bank?
)The generated mapper passes both the source object and mapping context to the converter.
fun FullAccount.toAccount(kommContext: AccountMapContext): Account = Account(
//...
bank = BankConverter(this, kommContext).convert(bankId)
)Use resolvers to provide destination values that cannot be read directly from the source object.
Create a resolver when a destination value should be supplied instead of read from the source.
class DateResolver(destination: DestinationObject?) : KOMMResolver<DestinationObject, Date>(destination) {
override fun resolve(): Date = Date.from(Instant.now())
}Apply @MapDefault to properties that should be resolved during mapper generation.
@KOMMMap(from = [SourceObject::class])
data class DestinationObject(
//...
@MapDefault<DateResolver>(DateResolver::class)
val activeDate: Date
) {
//...
@MapDefault<DateResolver>(DateResolver::class)
var otherDate: Date = Date.from(Instant.now())
}The generated mapper calls the resolver while constructing or updating the destination.
fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
//...
activeDate = DateResolver(null).resolve()
).also {
//...
it.otherDate = DateResolver(it).resolve()
}@MapDefault can also use a context-aware resolver when the mapping has KOMMMap.context.
data class TransactionMapContext(
val accounts: Map<Long, Account>,
val accountCurrencies: Map<Long, AccountCurrency>,
val categories: Map<Long, Category>
)
class FallbackAccountResolver(
destination: Transaction?,
context: TransactionMapContext
) : KOMMContextResolver<TransactionMapContext, Transaction, Account?>(destination, context) {
override fun resolve(): Account? {
return context.accounts.values.firstOrNull()
}
}Use a context-aware resolver when the default value depends on KOMMMap.context.
@KOMMMap(from = [DbTransaction::class], context = TransactionMapContext::class)
data class Transaction(
//...
@MapDefault<FallbackAccountResolver>(FallbackAccountResolver::class)
val expenseAccount: Account?
)The generated mapper passes the mapping context into the resolver.
fun DbTransaction.toTransaction(kommContext: TransactionMapContext): Transaction = Transaction(
//...
expenseAccount = FallbackAccountResolver(null, kommContext).resolve()
)Use @MapTargetDefault when a target property needs @MapDefault, but the target class cannot be annotated.
This is useful for to mappings into external models.
Declare the source mapping and class-level default metadata for the external target property.
data class AccountCardMapContext(val accountId: Long)
class AccountIdResolver(
destination: DbAccountCard?,
context: AccountCardMapContext
) : KOMMContextResolver<AccountCardMapContext, DbAccountCard, Long>(destination, context) {
override fun resolve(): Long = context.accountId
}
@KOMMMap(to = [DbAccountCard::class], context = AccountCardMapContext::class)
@MapTargetDefault(
name = "accountId",
default = MapDefault(AccountIdResolver::class),
`for` = [DbAccountCard::class]
)
data class AccountCard(
val id: Long,
val type: String,
val number: String
)The generated mapper resolves the target default while creating the external model.
fun AccountCard.toDbAccountCard(kommContext: AccountCardMapContext): DbAccountCard = DbAccountCard(
id = id,
accountId = AccountIdResolver(null, kommContext).resolve(),
type = type,
number = number
)Use null substitutes to map nullable source values into non-null destination properties.
Enable not-null assertions only when nullable source values are expected to be present at runtime.
@KOMMMap(
from = [SourceObject::class],
config = MapConfiguration(
allowNotNullAssertion = true
)
)
data class DestinationObject(
val id: Int
)
data class SourceObject(
val id: Int?
)fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
id = id!!
)e: [ksp] com.ucasoft.komm.processor.exceptions.KOMMCastException: Auto Not-Null Assertion is not allowed! You have to use @NullSubstitute annotation for id property.Define the fallback value provider used when the nullable source property is null.
class IntResolver(destination: DestinationObject?): KOMMResolver<DestinationObject, Int>(destination) {
override fun resolve() = 1
}Place @NullSubstitute on destination properties for mappings declared with from.
@KOMMMap(
from = [SourceObject::class]
)
data class DestinationObject(
@NullSubstitute(MapDefault(IntResolver::class))
val id: Int
) {
@NullSubstitute(MapDefault(IntResolver::class), "id")
var otherId: Int = 0
}The generated mapper uses the source value when present and falls back to the resolver when it is null.
fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
id = id ?: IntResolver(null).resolve()
).also {
it.otherId = id ?: IntResolver(it).resolve()
}Place @NullSubstitute on source properties for mappings declared with to.
@KOMMMap(
to = [DestinationObject::class]
)
data class SourceObject(
@NullSubstitute(MapDefault(IntResolver::class))
val id: Int?
) The generated mapper applies the substitute while creating the configured target type.
fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
id = id ?: IntResolver(null).resolve()
)Use multi-source mappings when the same destination type should be generated from more than one source type.
Configure each source type separately when one destination can be mapped from multiple models.
@KOMMMap(
from = [FirstSourceObject::class, SecondSourceObject::class]
)
data class DestinationObject(
@NullSubstitute(MapDefault(IntResolver::class), [FirstSourceObject::class])
@MapName("userId", [SecondSourceObject::class])
val id: Int
) {
@NullSubstitute(MapDefault(IntResolver::class), "id", [FirstSourceObject::class])
var otherId: Int = 0
}
data class FirstSourceObject(
val id: Int?
)
data class SecondSourceObject(
val userId: Int
)in case, different sources should be configured different:
@KOMMMap(
from = [FirstSourceObject::class],
config = MapConfiguration(
allowNotNullAssertion = true
)
)
@KOMMMap(
from = [SecondSourceObject::class]
)
data class DestinationObject(
@NullSubstitute(MapDefault(IntResolver::class), [FirstSourceObject::class])
@MapName("userId", [SecondSourceObject::class])
val id: Int
) {
@NullSubstitute(MapDefault(IntResolver::class), "id", [FirstSourceObject::class])
var otherId: Int = 0
}KOMM generates one extension function per configured source type.
fun FirstSourceObject.toDestinationObject(): DestinationObject = DestinationObject(
id = id ?: IntResolver(null).resolve()
).also {
it.otherId = id ?: IntResolver(it).resolve()
}
fun SecondSourceObject.toDestinationObject(): DestinationObject = DestinationObject(
id = userId
)Use the iterable plugin to map collection properties while converting their element types.
Add the iterable plugin alongside the KOMM processor for every target that needs collection mapping.
plugins {
id("com.google.devtools.ksp") version "2.3.9"
}
val kommVersion = "0.80.3"
dependencies {
implementation("com.ucasoft.komm:komm-annotations:$kommVersion")
ksp("com.ucasoft.komm:komm-processor:$kommVersion")
ksp("com.ucasoft.komm:komm-plugins-iterable:$kommVersion")
}plugins {
id("com.google.devtools.ksp") version "2.3.9"
}
val kommVersion = "0.80.3"
//...
dependencies {
add("kspJvm", "com.ucasoft.komm:komm-plugins-iterable:$kommVersion")
add("kspJvm", "com.ucasoft.komm:komm-processor:$kommVersion")
add("kspJs", "com.ucasoft.komm:komm-plugins-iterable:$kommVersion")
add("kspJs", "com.ucasoft.komm:komm-processor:$kommVersion")
// Add other platforms like `kspAndroidNativeX64`, `kspLinuxX64`, `kspMingwX64` etc.
}Allow not-null assertions when a nullable collection should be treated as present before element mapping.
class SourceObject {
val intList: List<Int>? = listOf(1, 2, 3)
}
@KOMMMap(from = [SourceObject::class], config = MapConfiguration(allowNotNullAssertion = true))
data class DestinationObject(
@MapName("intList")
val stringList: MutableList<String>
)public fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
stringList = intList!!.map { it.toString() }.toMutableList()
)Use @NullSubstitute when a nullable collection should fall back to a resolver.
class SourceObject {
val intList: List<Int>? = listOf(1, 2, 3)
}
@KOMMMap(from = [SourceObject::class])
data class DestinationObject(
@NullSubstitute(MapDefault(StringListResolver::class), "intList")
val stringList: MutableList<String>
)public fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
stringList = intList?.map { it.toString() }?.toMutableList() ?: StringListResolver(null).resolve()
)Use the Exposed plugin to generate mappers from database ResultRow values.
Add the Exposed plugin to JVM projects that map ResultRow values into application models.
plugins {
id("com.google.devtools.ksp") version "2.3.9"
}
val kommVersion = "0.80.3"
dependencies {
implementation("com.ucasoft.komm:komm-annotations:$kommVersion")
ksp("com.ucasoft.komm:komm-processor:$kommVersion")
ksp("com.ucasoft.komm:komm-plugins-exposed:$kommVersion")
}Annotate a destination model with an Exposed table source to generate a ResultRow mapper.
object SourceObject: Table() {
val id = integer("id").autoIncrement()
val name = varchar("name", 255)
val age = integer("age")
override val primaryKey = PrimaryKey(id)
}
@KOMMMap(from = [SourceObject::class])
data class DestinationObject(
val id: Int,
val name: String,
val age: Int
)public fun ResultRow.toDestinationObject(): DestinationObject = DestinationObject(
id = this[SourceObject.id],
name = this[SourceObject.name],
age = this[SourceObject.age]
)Use the enum plugin to map enum properties by constant names and configured fallbacks.
Add the enum plugin alongside the KOMM processor for targets that map enum properties.
plugins {
id("com.google.devtools.ksp") version "2.3.9"
}
val kommVersion = "0.80.3"
dependencies {
implementation("com.ucasoft.komm:komm-annotations:$kommVersion")
ksp("com.ucasoft.komm:komm-processor:$kommVersion")
ksp("com.ucasoft.komm:komm-plugins-enum:$kommVersion")
}plugins {
id("com.google.devtools.ksp") version "2.3.9"
}
val kommVersion = "0.80.3"
//...
dependencies {
add("kspJvm", "com.ucasoft.komm:komm-plugins-enum:$kommVersion")
add("kspJvm", "com.ucasoft.komm:komm-processor:$kommVersion")
add("kspJs", "com.ucasoft.komm:komm-plugins-enum:$kommVersion")
add("kspJs", "com.ucasoft.komm:komm-processor:$kommVersion")
// Add other platforms like `kspAndroidNativeX64`, `kspLinuxX64`, `kspMingwX64` etc.
}Use the enum plugin to map enum constants by name and optionally provide fallback behavior.
enum class SourceEnum {
UP,
DOWN,
LEFT,
RIGHT
}
data class SourceObject(
val direction: SourceEnum
)
@KOMMMap(from = [SourceObject::class])
data class DestinationObject(
val direction: DestinationObject.DestinationEnum
) {
enum class DestinationEnum {
UP,
DOWN,
OTHER
}
}fun SourceObject.toDestinationObject(): toDestinationObject = toDestinationObject(
direction = DestinationObject.DestinationEnum.valueOf(direction.name)
)data class SourceObject(
val direction: SourceEnum?
)
@KOMMMap(from = [SourceObject::class])
data class DestinationObject(
@NullSubstitute(MapDefault(DirectionResolver::class))
val direction: DestinationObject.DestinationEnum
)
class DirectionResolver(destination: DestinationEnum?) : KOMMResolver<DestinationEnum, DestinationObject.DestinationEnum>(destination) {
override fun resolve() = DestinationObject.DestinationEnum.OTHER
}fun SourceObject.toDestinationObject(): toDestinationObject = toDestinationObject(
direction = (if (direction != null) DestinationObject.DestinationEnum.valueOf(direction.name) else null)
?: DirectionResolver(null).resolve()
)data class SourceObject(
val direction: SourceEnum?
)
@KOMMMap(from = [SourceObject::class])
data class DestinationObject(
@NullSubstitute(MapDefault(DirectionResolver::class))
@KOMMEnum("OTHER")
val direction: DestinationObject.DestinationEnum
)
class DirectionResolver(destination: DestinationEnum?) : KOMMResolver<DestinationEnum, DestinationObject.DestinationEnum>(destination) {
override fun resolve() = DestinationObject.DestinationEnum.OTHER
}fun SourceObject.toDestinationObject(): toDestinationObject = toDestinationObject(
direction = (if (direction != null) DestinationObject.DestinationEnum.valueOf(if
(DestinationObject.DestinationEnum.entries.any { it.name == direction.name }) direction.name else "OTHER")
else null) ?: DirectionResolver(null).resolve()
)