Kotlin

Generate a native Kotlin client from your routes, using OkHttp and kotlinx.serialization.

@kizunajs/kotlin generates a native Kotlin client from your Kizuna routes. The generated client uses OkHttp for HTTP, kotlinx.serialization for JSON, and Kotlin coroutines for async.

pnpm add @kizunajs/kotlin@beta

Add the dependencies

The generated client uses OkHttp, kotlinx.serialization, and coroutines. Add them to your build.gradle.kts:

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")
}

Add it to your config

Point kotlinClient at the file you want written:

kizuna.config.ts
import { kotlinClient } from '@kizunajs/kotlin';

export default defineConfig({
    adapter: expressAdapter(),
    routes,
    clients: [
        kotlinClient({
            output: './android/app/src/main/kotlin/com/example/APIClient.kt',
            namespace: 'API',
            package: 'com.example',
        }),
    ],
});
kizuna generate
OptionDescription
outputWhere the generated .kt file goes
namespaceObject wrapping all generated types, API by default
packagePackage declaration for the generated file, such as com.example.api
camelCasePropertiesConvert wire field names to camelCase properties, mapped back via @SerialName
unknownEnumCaseEmit enums as a sealed interface with an Unknown member so new server values don't break decoding

The path is relative to the config, so a client can land outside the TypeScript project. When the Android app lives in the same repository, ../android/app/src/main/kotlin/com/example/APIClient.kt writes straight into it. An app in its own repository takes the generated file the way it takes any other vendored source, by copying it in from a build step or committing it.

Add it to your project

Add the generated file to your Kotlin project as a regular source file. Set package so the file declares a package matching its directory, which Android and JVM projects expect.

Call a route

Request inputs are built inside a lambda: params (path), query, headers, and body. You name no types, and missing a required field is a compile error:

APISetup.kt
val client = APIClient(baseUrl = "https://api.example.com")
UserService.kt
// 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",
    )
}

Use it in a screen

A schema wrapped in Kizuna.model generates a named type, so a composable can name it in its signature and every route that returns a user hands back the same API.User:

UserRow.kt
@Composable
fun UserRow(user: API.User) {
    Column {
        Text(user.name)
        Text(user.id, style = MaterialTheme.typography.bodySmall)
    }
}
UserList.kt
@Composable
fun UserList(client: APIClient) {
    var users by remember { mutableStateOf(emptyList<API.User>()) }

    LaunchedEffect(Unit) {
        users = client.users.listUsers().body.users
    }

    LazyColumn {
        items(users, key = { it.id }) { user ->
            UserRow(user)
        }
    }
}

Branded IDs

A branded ID becomes a value class, API.UserId, serialized as the bare string. The parameter's type shows it, and passing a String does not compile.

An ID from a response is already an API.UserId, so it passes into the next call as it is:

val created = client.users.createUser {
    body(
        name = "Ada Lovelace",
        email = "ada@example.com",
    )
}

client.users.deleteUser {
    params(
        id = created.body.id,
    )
}

A String from outside the API, like a deep link, is wrapped once:

val userId = uri.lastPathSegment ?: return

val user = client.users.getUser {
    params(
        id = API.UserId(userId),
    )
}

value reads the string back:

Text(user.body.id.value)

Optional and null fields

An .optional() or .nullish() field is a String? that defaults to null and is left out of the request. A .nullable() field is a String? with no default, sent as null when you pass null:

client.users.updatePreferences {
    params(
        id = userId,
    ).body(
        timezone = null,
        mutedChannels = emptyList(),
    )
}

See Optional and null fields for which to declare.

Property naming

By default, field names are emitted verbatim, so total_count on the wire stays total_count in the generated type. A @file:Suppress(...) header keeps the IDE green over the generated file.

Pass --camel-case to convert wire fields to camelCase properties (total_count becomes totalCount), preserving the wire name via @SerialName.

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:

EventFeed.kt
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.

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:

APIClient.kt
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):

APISetup.kt
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:

UserService.kt
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
    }
}

Methods throw, so runCatching works for a functional style:

UserService.kt
val result = runCatching {
    client.users.getUser {
        params(
            id = "1",
        )
    }
}
result.onSuccess { println(it.body) }.onFailure { println("failed: ${it.message}") }

Deprecation

Routes with deprecated and fields with .meta({ deprecated: ... }) emit @Deprecated in the generated Kotlin code, with the message when one is set:

APIClient.kt
@Deprecated("use newRoute instead")
suspend fun deleteUser(build: APIClient.UsersDeleteUser.Scope.() -> APIClient.UsersDeleteUser.Args) {
    // ...
}

Streams

A route whose response streams returns its body as a Flow. Named events become a sealed Event interface with one class per event, each carrying its decoded payload as data:

AssistantScreen.kt
val result = client.assistant.reply {
    body(
        prompt = "hello",
    )
}
result.body.collect { event ->
    when (event) {
        is APIClient.AssistantReply.Event.Delta -> text.append(event.data.text)
        is APIClient.AssistantReply.Event.Done -> println(event.data.outputTokens)
    }
}

A stream of one schema yields that type directly, a text/* stream yields one String per line, and a binary stream yields ByteArray chunks. The status and headers are checked before the method returns, so a 400 throws its Failure as on any route. The connection is open when the method returns, so collect the flow; cancelling the collector closes it. A route that mixes a streamed status with another 2xx is left out of the client, with a warning.

Keeping it current

kizuna generate writes every client the config declares, so the Kotlin one is never generated on its own:

package.json
{
    "scripts": {
        "kizuna:generate": "kizuna generate",
        "kizuna:check": "kizuna generate --check"
    }
}

Run kizuna generate --check in CI, and a Kotlin client nobody regenerated fails the build instead of drifting from the routes it was generated from.

Reference