Kotlin
Generate a native Kotlin client from your contract with the ts-kizuna-kotlin CLI.
The Kotlin client is new and still settling. The generated API surface may change before v2, so pin your version if you depend on it.
@ts-kizuna/kotlin generates a native Kotlin client from your ts-kizuna contract. The generated client uses OkHttp for HTTP, kotlinx.serialization for JSON, and Kotlin coroutines for async.
pnpm add @ts-kizuna/kotlinbun add @ts-kizuna/kotlinnpm install @ts-kizuna/kotlinGenerate
ts-kizuna-kotlin generate --contract src/contract.ts --out android/app/src/main/kotlin/com/example/APIClient.kt --namespace-name API --package com.example| Flag | Description |
|---|---|
--contract | Path to the TypeScript file that exports the contract |
--out | Output path for the generated .kt file |
--namespace-name | Object wrapping all generated types (e.g. API) |
--package | Optional package declaration for the generated file (e.g. com.example.api) |
--camel-case | Convert wire field names to camelCase properties, mapped back via @SerialName |
--unknown-enum-case | Emit enums as a sealed interface with an Unknown member so new server values don't break decoding |
--export | Export name to read when none is suffixed on --contract. Default: contract |
The CLI reads the contract at runtime, with no build step needed. Add the generated file to your Kotlin project as a regular source file. Pass --package so the file declares a package matching its directory (Android/JVM projects expect this).
Property naming
By default, field names are emitted verbatim, so total_count on the wire stays total_count in the generated type. The types mirror the wire shape, so it's easy to see what came from the API. A @file:Suppress(...) header keeps the IDE green: the snake_case naming inspections (PropertyName, LocalVariableName, ConstructorParameterNaming) for the verbatim names, plus a few that always apply: SpellCheckingInspection for brand terms, unused for methods only your own code calls, and the Redundant* style inspections.
Pass --camel-case to convert wire fields to camelCase properties (total_count becomes totalCount), preserving the wire name via @SerialName. The naming inspections drop out of the header then, since there are no underscores left to flag, while the rest stay.
Unknown enum case (open enums)
By default a z.enum is a closed enum class that throws on a wire value it doesn't know, failing the whole response. Pass --unknown-enum-case to make enums forward-compatible: each becomes a sealed interface with a data object per known value plus an Unknown(wireValue) member, so an unrecognised value deserializes to Unknown("newValue") instead of throwing:
when (val kind = event.kind) {
EventKind.LOGIN, EventKind.LOGOUT, EventKind.SIGNUP -> render(event)
is EventKind.Unknown -> log("skipping unknown event kind: ${kind.wireValue}")
}The raw value is preserved as wireValue, so re-encoding writes it back unchanged. Discriminated unions still throw on an unknown discriminator.
Usage
Request inputs are built inside a lambda: params (path), query, headers, and body. No type names, autocomplete guides you, and missing a required field is a compile error:
val client = APIClient(baseUrl = "https://api.example.com")
// path param + header, groups chain in order
val user = client.users.getUser {
params(
id = "1",
).headers(
xRequestId = "trace-1",
)
}
// query, all-optional, so it can be omitted entirely
val page = client.users.listUsers {
query(
page = 1,
limit = 20,
)
}
val all = client.users.listUsers()
// object body
val created = client.users.createUser {
body(
name = "Ada",
email = "ada@example.com",
)
}Each method returns a Result holding the decoded body (no unwrapping, no casting) and throws a typed Failure for any error status. The happy path just reads response.body:
println(page.body.users)Void routes return Unit, so call them and let errors propagate:
client.users.deleteUser {
params(
id = "1",
)
}Catch a specific error only when you care; everything else propagates:
try {
val response = client.users.getUser {
params(
id = "1",
)
}
println(response.body)
} catch (error: APIClient.UsersGetUser.Failure.NotFound) {
println("missing: ${error.body.detail}")
}Generated client
The generated file exports a typed class with suspend methods for each route and sub-clients for grouped routes. Each method takes a request-builder lambda whose receiver (Scope) exposes one factory per input group. The lambda must return the operation's Args, and only chains that provide every required group produce an Args, which is what makes missing required inputs a compile error. Each method returns the route's Response (or Unit for routes with no success body) and throws its Failure:
import kotlinx.serialization.*
import okhttp3.*
class APIClient(
private val baseUrl: String,
private val client: OkHttpClient = OkHttpClient(),
private val json: Json = Json { ignoreUnknownKeys = true },
private val requestInterceptor: (suspend (Request.Builder) -> Unit)? = null,
private val responseInterceptor: (suspend (Request, Response) -> Unit)? = null
) {
val users = APIUsersClient(...)
object UsersListUsers {
data class Query(
val page: Int? = null,
val limit: Int? = null
)
sealed interface Args {
val query: Query?
}
object Scope {
fun query(page: Int? = null, limit: Int? = null): AfterQuery = ...
}
// AfterQuery, Response, Result, Success, Failure ...
}
@Throws(UsersListUsers.Failure::class)
suspend fun listUsers(build: APIClient.UsersListUsers.Scope.() -> APIClient.UsersListUsers.Args = { query() }): APIClient.UsersListUsers.Result {
// ...
}
}Interceptors & auth
requestInterceptor runs before every request, so attach auth headers here. responseInterceptor observes every response. Both may suspend (e.g. to refresh a token):
val client = APIClient(
baseUrl = "https://api.example.com",
requestInterceptor = { builder ->
builder.header("Authorization", "Bearer $token")
},
responseInterceptor = { request, response ->
println("${request.method} ${request.url} -> ${response.code}")
}
)Handling responses
Success returns a Result with the decoded body (and headers when the route declares them). Routes with multiple success codes give you a sealed Success to when over; void routes return Unit.
Errors throw a sealed Failure with one named subtype per declared status (NotFound, BadRequest, …) carrying its typed body, plus Unexpected(statusCode, data) for undeclared statuses and Decoding(cause, statusCode, data) for bodies that don't parse. Catch one subtype, or when over the sealed class, and the compiler lists every case:
try {
val response = client.users.getUser {
params(
id = "1",
)
}
println(response.body)
} catch (error: APIClient.UsersGetUser.Failure) {
when (error) {
is APIClient.UsersGetUser.Failure.NotFound -> println("missing: ${error.body.detail}")
is APIClient.UsersGetUser.Failure.Unexpected -> println("status ${error.statusCode}")
is APIClient.UsersGetUser.Failure.Decoding -> throw error
}
}Prefer a functional style? Methods throw, so runCatching just works:
val result = runCatching {
client.users.getUser {
params(
id = "1",
)
}
}
result.onSuccess { println(it.body) }.onFailure { println("failed: ${it.message}") }Dependencies
Add these to your build.gradle.kts:
dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
}Deprecation
Routes and fields marked /** @deprecated */ in the contract emit @Deprecated in the generated Kotlin code:
@Deprecated("use newRoute instead")
suspend fun deleteUser(build: APIClient.UsersDeleteUser.Scope.() -> APIClient.UsersDeleteUser.Args) {
// ...
}Tip: automate with a script
You can add a script to your package.json to regenerate the client whenever the contract changes:
{
"scripts": {
"generate:kotlin": "ts-kizuna-kotlin generate --contract src/contract.ts --out ../android/app/src/main/kotlin/com/example/APIClient.kt --namespace-name API --package com.example"
}
}