Skip to content
All Libraries

Composure

Type-safe, coroutine-based form state and validation for Kotlin Multiplatform, sharing one form definition between Compose Multiplatform and SwiftUI.

GitHub
version <version> Kotlin Multiplatform Apache 2.0 Maven Central
On this page

Overview

Composure is a form validation engine you define once, in Kotlin, and drive from both Compose Multiplatform and SwiftUI: same fields, same validators, same async-debounce behavior, no parallel validation logic to keep in sync on the Swift side.

  • composure-core is the engine: typed fields, sync/async validators, StateFlow-based observable state. Pure Kotlin, no UI framework dependency.
  • composure-compose provides rememberFormState, wiring a FormScope into Compose’s lifecycle.
  • composure-ios is a Swift-friendly bridge (ComposureFormScope, FormField) that exposes the same engine to SwiftUI without leaking coroutines or generics across the interop boundary.

Platforms: Android, iOS, Desktop (JVM) via composure-core/composure-compose; native SwiftUI via composure-ios.


Installation

Compose Multiplatform

commonMain.dependencies {
    implementation("io.github.kmpbits:composure-core:<version>")
    implementation("io.github.kmpbits:composure-compose:<version>")
}

Replace <version> with the latest release on GitHub or Maven Central.

SwiftUI (Swift Package Manager)

  1. In Xcode, go to File → Add Package Dependencies…
  2. Paste the repository URL:
    https://github.com/kmpbits/Composure.git
  3. Pick a version rule, click Add Package, then select the Composure product for your target.

Or add it to your own Package.swift:

.package(url: "https://github.com/kmpbits/Composure.git", from: "<version>")

Then import composure_ios in Swift.


Usage

Compose Multiplatform: inline

val form = rememberFormState {
    field("email", Email)
    field("password", Password) { minLength(8) }
}

val email by form.get<Email>("email").value.collectAsState()
val isValid by form.isValid.collectAsState()

OutlinedTextField(
    value = email,
    onValueChange = { form.get<Email>("email").onChange(it) },
    label = { Text("Email") },
)

Button(onClick = form.handleSubmit { values -> /* submit */ }, enabled = isValid) {
    Text("Sign in")
}

Compose Multiplatform: typed form class

class RegistrationForm(scope: FormScope) : FormController by scope {
    val email = scope.field(Email) { async(checkEmailAvailability) }
    val password = scope.field(Password) { minLength(8); hasUppercase(); hasDigit() }
    val confirm = scope.field(Password) { mustMatch(password) }
}

val form = rememberFormState { scope -> RegistrationForm(scope) }

Delegating FormController to scope exposes isValid, isDirty, isSubmitting, and handleSubmit()/reset() directly on the form class, alongside its own typed field properties.

SwiftUI

class RegistrationForm: ComposureForm {
    lazy var email = makeEmailField()
    lazy var password = makePasswordField()
    lazy var confirm = makeConfirmField(matching: password)
}

ComposureForm/ComposureField wrap ComposureFormScope/FormField into ObservableObject/SwiftUI View types, so field updates drive @Published properties with no manual coroutine bridging. These wrapper types aren’t part of the published composure-ios package: it ships the Kotlin bridge only. Copy ComposureFormKit.swift from the sample app into your project as a starting point.


API

FormScope

The form-building scope and runtime controller, passed into your form class (or inline block) by rememberFormState.

class FormScope(
    coroutineScope: CoroutineScope,
    asyncDebounceMs: Long = 300L,
) : FormController

Declare fields with field(type) { ... } (typed property access) or field(name, type) { ... } (string-keyed, retrieved later with form.get<Type>(name)). Implements FormController: isValid, isDirty, isSubmitting, submitError as StateFlow, plus handleSubmit(onValid) and reset().

Field types

Built-in FieldTypes pre-bake sensible default validators:

TypeKeyboard hintDefault validators
EmailEmailrequired, email format
PasswordPassword (masked)required
NameTextrequired
PhonePhonerequired
TextTextnone

Implement FieldType yourself for custom types, with your own default validators and DSL extensions.

Validators

required(), email(), minLength(), maxLength(), matches(), hasUppercase(), hasDigit(), hasSpecialChar(), plus type-specific extensions like Password.mustMatch(other) for cross-field validation. The dependent field re-validates automatically whenever the field it depends on changes, no manual wiring required.

Async validation (server-side uniqueness checks, etc.) attaches per field via async(validator) and is debounced by asyncDebounceMs (default 300ms) so it doesn’t fire on every keystroke.

Every built-in validator takes a message parameter, and messages() overrides a type’s default required/format messages:

val email = scope.field(Email) {
    messages(required = "We need your email", format = "That doesn't look like an email")
}

val password = scope.field(Password) {
    minLength(8, "Needs to be at least 8 characters")
}

For fully custom validation logic, use addValidator inside the field’s builder block:

val username = scope.field(Text) {
    addValidator { value ->
        if (value.contains(" ")) ValidationResult.Invalid("No spaces allowed")
        else ValidationResult.Valid
    }
}

iOS bridge

ComposureFormScope owns its own FormScope and coroutine scope, and exposes field factories (emailField(), passwordField(), confirmField()) returning FormField: a type-erased wrapper Swift can observe (watchValue, watchError, watchIsTouched, watchIsDirty, watchIsValidating) without touching Kotlin generics or StateFlow directly. asyncValidator { value, callback in ... } bridges a Swift async check into the same AsyncFieldValidator interface the Compose side uses.


Known Limitations

  • No bundled SwiftUI view layer: composure-ios ships the Kotlin bridge only. The ComposureForm/ComposureField SwiftUI types are a reference implementation in the sample app, meant to be copied and adapted, not a transitive dependency.
  • No built-in input masking: validators reject or accept a value; formatting input as the user types (e.g. phone number masks) stays the UI layer’s responsibility.
Maintained by KMP Bits View source on GitHub →