Clients

Swift

Generate a native Swift client from your routes with the ts-kizuna-swift CLI.

@ts-kizuna/swift generates a native Swift client from your ts-kizuna routes. The generated client uses URLSession and Codable, with no third-party Swift dependencies required.

pnpm add @ts-kizuna/swift
bun add @ts-kizuna/swift
npm install @ts-kizuna/swift

Generate

ts-kizuna-swift generate --contract src/contract.ts --output ios/MyApp/Generated/APIClient.swift --namespace-name API
FlagDescription
--contractPath to the TypeScript file default-exporting your k.contract
--outputOutput path for the generated .swift file
--namespace-namePublic enum wrapping all generated types (e.g. API)
--camel-caseConvert wire field names to camelCase properties with CodingKeys
--unknown-enum-caseEmit enums with an unknown(String) fallback so new server values don't break decoding

The CLI reads the contract at runtime using jiti, with no build step needed. Add the generated file to your Xcode project or Swift package as a regular source file.

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.

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

Unknown enum case (open enums)

By default a z.enum is a closed Swift enum that throws on a wire value it doesn't know, failing the whole response. Pass --unknown-enum-case to make enums forward-compatible: each gains an unknown(String) case, so an unrecognised value decodes to .unknown("newValue") instead of throwing:

switch event.kind {
case .login, .logout, .signup:
    render(event)
case .unknown(let raw):
    log("skipping unknown event kind: \(raw)")
}

The raw value is preserved, so re-encoding writes it back unchanged. Discriminated unions still throw on an unknown discriminator.

Usage

Create the client once and share it across your app:

APISetup.swift
let client = APIClient(baseURL: URL(string: "https://api.example.com")!)

Request inputs are passed as components named after the contract's input groups: .params (path), .body, .query, and .headers:

UserService.swift
// object body
let created = try await client.users.createUser(
    .body(
        name: "Ada",
        email: "ada@example.com"
    )
)

// path param + header
let user = try await client.users.getUser(
    .params(id: "42"),
    .headers(xRequestId: "trace-1")
)

// query
let page = try await client.users.listUsers(
    .query(page: 1, limit: 20)
)
let all = try await client.users.listUsers()

// discriminated-union body
try await client.sendNotification(
    .body(
        .email(to: "ada@example.com", subject: "Hi")
    )
)

Generated client

The generated file exports a Sendable client with methods for each route and sub-clients for grouped routes. It's a plain final class, and everything is immutable let storage set at init, so it's safe to share across tasks without an actor hop per request. Each request group is a nested struct with a group-named factory:

APIClient.swift
import Foundation

public final class APIClient: Sendable {
    public let baseURL: URL
    public let session: URLSession

    public var users: APIUsersClient { ... }

    public func listUsers(_ query: APIClient.ListUsers.Query = .query()) async throws(APIClient.ListUsers.Failure) -> APIClient.ListUsers.Result {
        // ...
    }

    public enum ListUsers {
        public struct Query: Sendable {
            public let page: Int?
            public let limit: Int?
            public init(page: Int? = nil, limit: Int? = nil) { ... }
            public static func query(page: Int? = nil, limit: Int? = nil) -> Self { .init(page: page, limit: limit) }
        }
        // Response, Result, Failure ...
    }
}

Deprecation

Routes and fields marked /** @deprecated */ in the routes emit @available(*, deprecated) in the generated Swift code:

@available(*, deprecated)
public func deleteUser(_ params: APIClient.DeleteUser.Params) async throws(APIClient.DeleteUser.Failure) {
    // ...
}

Tip: automate with a script

You can add a script to your package.json to regenerate the client whenever the contract changes:

package.json
{
    "scripts": {
        "generate:swift": "ts-kizuna-swift generate --contract src/contract.ts --output ../ios/MyApp/Generated/APIClient.swift --namespace-name API"
    }
}

Reference

On this page