kap-ksp¶
KSP processor that makes same-type parameter swaps a compile error.
plugins {
id("com.google.devtools.ksp") version "2.3.6"
}
dependencies {
implementation("io.github.damian-rafael-lattenero:kap-ksp-annotations:2.3.0")
ksp("io.github.damian-rafael-lattenero:kap-ksp:2.3.0")
}
Unreleased
This module is available in source but not yet published to Maven Central. To use it now, build from source. Maven Central publication is planned for the next release.
Depends on: KSP2 2.3.6 (compatible with Kotlin 2.3.20).
The Problem¶
KAP catches type swaps when types differ. But when two parameters share a type, the swap is silent:
data class User(val firstName: String, val lastName: String, val age: Int)
kap(::User)
.with { fetchLastName() } // String ← WRONG ORDER
.with { fetchFirstName() } // String ← WRONG ORDER
.with { fetchAge() } // Int ← this one is safe
// Compiles. Wrong data. Production bug.
This is the same problem raw coroutines and Arrow have. No type system can catch it — unless you give each parameter a distinct type.
The Solution¶
@KapTypeSafe generates distinct wrapper types automatically:
@KapTypeSafe
data class User(val firstName: String, val lastName: String, val age: Int)
// KSP generates:
// data class UserFirstName(val value: String)
// data class UserLastName(val value: String)
// data class UserAge(val value: Int)
// fun kapSafe(f: (String, String, Int) -> User): Kap<(UserFirstName) -> (UserLastName) -> (UserAge) -> User>
// fun String.toFirstName(): UserFirstName
// fun String.toLastName(): UserLastName
// fun Int.toAge(): UserAge
Usage — clean, fluent, compile-time safe:
kapSafe(::User)
.with { fetchFirstName().toFirstName() } // UserFirstName
.with { fetchLastName().toLastName() } // UserLastName — swap? COMPILE ERROR
.with { fetchAge().toAge() } // UserAge
Multiplatform compatible — uses data class wrappers that compile on every Kotlin target (JVM, JS, WASM, Native, iOS, macOS). Minimal overhead — one small object per wrapper, negligible compared to the network calls being wrapped.
Works on Functions Too¶
Not just constructors — any function:
data class Dashboard(val userName: String, val cartSummary: String, val promoCode: String)
@KapTypeSafe
fun buildDashboard(userName: String, cartSummary: String, promoCode: String): Dashboard =
Dashboard(userName, cartSummary, promoCode)
// Generated: kapSafeBuildDashboard(), .toUserName(), .toCartSummary(), .toPromoCode()
kapSafeBuildDashboard(::buildDashboard)
.with { fetchUserName().toUserName() }
.with { fetchCartSummary().toCartSummary() }
.with { fetchPromoCode().toPromoCode() }
Generated function name: kapSafe for classes, kapSafe{FunctionName} for functions.
Prefix — Avoiding Collisions¶
Two functions with the same parameter name? Use prefix:
@KapTypeSafe(prefix = "Dashboard")
fun buildDashboard(userName: String, cartSummary: String, promoCode: String): Dashboard = ...
@KapTypeSafe(prefix = "Report")
fun buildReport(userName: String, dateRange: String, format: String): Report = ...
Both have userName: String, but no collision:
// Dashboard
kapSafeBuildDashboard(::buildDashboard)
.with { fetchUserName().toDashboardUserName() } // no collision
.with { fetchCartSummary().toDashboardCartSummary() }
.with { fetchPromoCode().toDashboardPromoCode() }
// Report
kapSafeBuildReport(::buildReport)
.with { fetchUserName().toReportUserName() } // no collision
.with { fetchDateRange().toReportDateRange() }
.with { fetchFormat().toReportFormat() }
Default is no prefix — clean and short. Add prefix only when you need it.
What Gets Generated¶
For each @KapTypeSafe annotated class or function:
| Generated | Example |
|---|---|
| Data class per param | data class UserFirstName(val value: String) |
kapSafe function |
fun kapSafe(f: (String, String, Int) -> User): Kap<(UserFirstName) -> ...> |
| Extension per param | fun String.toFirstName(): UserFirstName |