A simple but powerful form validator for Compose Multiplatform. One field + one rule is one line; the full feature set covers async server-side checks, cross-field rules, multi-step wizards, conditional fields, server-error injection, and i18n — without dragging in a heavy framework.
cmp-formis published as a Kotlin Multiplatform library, but you don't need any KMP setup to use it. Drop it into a regular:appmodule'sdependencies { ... }block and Gradle picks the Android variant automatically — same as Coil, Ktor, Paging, or any other modern KMP library.// app/build.gradle.kts dependencies { implementation("io.github.nadeemiqbal:cmp-form:0.1.0") implementation("io.github.nadeemiqbal:cmp-form-material3:0.1.0") }Requires
minSdk >= 24and a Compose version compatible with CMP 1.10 (androidx.compose 1.7+). Thecmp-form-material3artifact resolves to the standardandroidx.compose.material3on Android, so existing M3 imports work alongside it with no conflicts. See Android-only setup below for the full snippet.
Forms in Compose are tedious: a tangle of MutableState, remember, focus-aware blur tracking,
async lookups, and ad-hoc cross-field validation. cmp-form gives you a typed FieldState<T> and
a builder DSL that own the boring parts so your screen code stays the size of a JSX form.
It ships in two artifacts so the validator/state core stays Material3-free for users who bind to a custom design system:
| Artifact | What you get | Compose deps |
|---|---|---|
cmp-form |
Validator<T>, ValidationResult, every built-in rule, FieldState<T>, FormState, the rememberFormState { ... } DSL, Modifier.formField() |
runtime + foundation + ui |
cmp-form-material3 |
Drop-in FormTextField, FormCheckbox, FormSwitch, FormRadioGroup, FormSlider, FormDatePicker — wraps M3 components and wires the error state automatically. Transitively brings cmp-form. |
+ material3 |
| Platform | Supported | Tested |
|---|---|---|
| Android | ✅ | ✅ (unit + sample APK) |
| iOS | ✅ | ✅ (compile) |
| Desktop | ✅ | ✅ (unit + sample) |
| Web | ✅ | ✅ (compile + sample) |
gradle/libs.versions.toml:
[libraries]
cmp-form = { module = "io.github.nadeemiqbal:cmp-form", version = "0.1.0" }
cmp-form-material3 = { module = "io.github.nadeemiqbal:cmp-form-material3", version = "0.1.0" }commonMain dependencies:
commonMain.dependencies {
implementation(libs.cmp.form) // core: validators + state + DSL
implementation(libs.cmp.form.material3) // optional: drop-in M3 composables
}If you're not using Compose Multiplatform — just a regular com.android.application /
com.android.library module with Compose — add the dependencies straight to the Android module:
// app/build.gradle.kts
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
defaultConfig {
minSdk = 24 // cmp-form requires API 24+
}
buildFeatures { compose = true }
}
dependencies {
implementation("io.github.nadeemiqbal:cmp-form:0.1.0")
implementation("io.github.nadeemiqbal:cmp-form-material3:0.1.0")
// Your existing Compose deps — cmp-form does NOT replace these.
implementation(platform("androidx.compose:compose-bom:2024.10.00"))
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.9.3")
}That's it — every FormTextField, rememberFormState, validator function in this README works
identically. Gradle resolves the Android variant of the KMP publication automatically; you don't
need to apply the Kotlin Multiplatform plugin or use commonMain.
Requirements / things to verify:
minSdk >= 24— bump your module'sminSdkif you're on 21–23, otherwise the manifest merger will fail.- Compose compatibility —
cmp-formis built against Compose Multiplatform 1.10.3, which is binary-compatible withandroidx.compose1.7+ (Compose BOM 2024.10 or later). If you're on an older BOM, upgrade. - Kotlin compatibility — built with Kotlin 2.3.21. Your project's Kotlin should be the same major version (the standard Compose-Kotlin compatibility matrix applies).
- Transitive deps — adds
kotlinx-datetime(~80 KB, used by date/age validators) andkotlinx-coroutines-core. No other runtime dependencies. - Material3 conflicts —
cmp-form-material3depends onorg.jetbrains.compose.material3, which resolves to the sameandroidx.compose.material3:material3artifact on Android. Same package names, no duplicate-class errors —import androidx.compose.material3.Buttonworks alongsideimport io.github.nadeemiqbal.cmpform.material3.FormTextField.
If you only want the headless validator/state core (no Material3 composables), drop
cmp-form-material3 and depend on cmp-form alone — you can then bind FieldState to any
TextField (M2, M3, or your own design system) by wiring field.value, field::onValueChange,
field.hasError, and field.errorText manually.
val form = rememberFormState {
field("email", "") { validator = required() and email() }
field("password", "") { validator = required() and minLength(8) }
}
FormTextField(field = form.field("email"), label = { Text("Email") })
FormTextField(field = form.field("password"), label = { Text("Password") })
Button(onClick = {
scope.launch {
form.submit { values -> login(values["email"] as String, values["password"] as String) }
}
}) { Text("Login") }That's a complete, validated login form. required() rejects blanks, email() checks the
format, minLength(8) enforces password length. The DSL hides the MutableState, focus
listeners and "did the user touch the field yet?" bookkeeping.
Presence & length — required(), requiredNotBlank(), notNull<T>(), mustBeTrue(),
nonEmpty<T>(), sizeRange<T>(), minLength(), maxLength(), exactLength(), lengthRange().
Character classes — alphabetic(), numeric(), alphanumeric(), ascii(),
noWhitespace(), noLeadingTrailingSpace(), containsDigit(), containsUppercase(),
containsLowercase(), containsSpecialChar().
Formats — email(), url(), phone(), ipv4(), ipv6(), hostname(), slug(),
uuid(), hexColor(), pattern(regex), postalCode(country) (~25 countries bundled).
Numeric — min(), max(), range() (any Comparable<T>), positive(), negative(),
nonNegative(), nonPositive(), multipleOf() (Int/Long), integer(), decimal(),
currency() (String-parseability).
Comparison — equalTo(), notEqualTo(), oneOf(...), notOneOf(...).
Dates (via kotlinx-datetime) — isoDate(), dateInRange(), minAge(), maxAge(),
ageRange(), inFuture(), inPast(), weekday(), weekend().
Financial — creditCard() with Luhn + brand restriction, iban() (mod-97), bic() (SWIFT).
Passwords — password { minLength(8); upper(); lower(); digit(); special(); noCommon() }
DSL aggregator, passwordStrength(Strong) for entropy thresholds, scorePassword() for an
unbounded 0..4 strength meter.
Cross-field — matchesField("otherKey"), differentFromField("otherKey"),
whenField("otherKey", predicate, then).
Files — FileSpec value-object + fileSize(), fileExtension(), mimeType() (with
"image/*" wildcard support).
Combinators — a and b (short-circuit), a or b, a andAll b (accumulate every failure),
not(v), whenever(predicate, v), and a contramap(transform) operator for adapting a
validator to a different input type.
Escape hatches — custom("code", "message") { predicate } for sync,
customSuspend("code", "message") { suspendPredicate } for async, plus inline
asyncValidator = ... in the DSL.
field("username", "") {
validator = required() and minLength(3) and slug()
asyncValidator = customSuspend("taken", "Username is taken") {
api.usernameAvailable(it)
}
debounce = 400.milliseconds
}field.isValidating is observable, submit() waits for async checks to resolve before invoking
the handler, and the debounce auto-cancels in-flight requests on new keystrokes.
Per-field, configurable via triggerMode = ...:
| Mode | Behavior |
|---|---|
OnSubmit |
Silent until form.submit() / validateAll(). |
OnBlur |
Validates when focus leaves. |
OnChange |
Validates on every keystroke. |
OnChangeAfterBlur (default) |
Silent until first blur, then live. The Yup/Formik default. |
OnChangeAfterSubmit |
Silent until the first submit(), then live. |
Manual |
Caller drives field.validate(). |
field("password", "") { validator = required() and minLength(8) }
field("confirm", "") { validator = required() and matchesField("password") }Or a form-level rule that runs against the whole snapshot:
formRule("dates") {
if ((get<Int>("start") ?: 0) > (get<Int>("end") ?: 0))
invalid("order", "Start must be ≤ end")
else valid()
}val form = rememberFormState {
step("account") { field("email", "") { ... } }
step("personal") { field("name", "") { ... } }
}
form.nextStep() // advances only when the current step is validenabledWhen { snapshot -> ... } on a field excludes it from validation and from values
when the predicate is false — useful for fields like "Company" that only matter if "Employed"
is checked.
try { api.signup(form.values) }
catch (e: HttpException) {
form.setServerErrors(e.fieldErrors) // { "email" to "already registered", ... }
}object FrenchMessages : FormMessages {
override fun messageFor(code: String, params: Map<String, Any?>) = when (code) {
"required" -> "Champ obligatoire"
"email" -> "Adresse e-mail invalide"
else -> DefaultMessages.messageFor(code, params)
}
}
CompositionLocalProvider(LocalFormMessages provides FrenchMessages) { SignupScreen() }Each ValidationError also carries a code, so apps using stringResource() can map codes
to platform-native string resources themselves.
rememberFormState is backed by rememberSaveable with a Saver that round-trips primitive
field values automatically. Reference-typed fields fall back to in-memory (still survive
recomposition, but not Activity recreation).
A four-screen sample lives under sample/composeApp/: Login, Signup
(with async username check), Checkout (credit-card + IBAN + postal-by-country) and a
3-step Wizard (with enabledWhen conditional fields).
Run it:
./gradlew :sample:desktopApp:run
./gradlew :sample:webApp:wasmJsBrowserDevelopmentRun
./gradlew :sample:androidApp:installDebugSee out-of-scope in the design doc for the v0.2+ wishlist. Highlights:
Flow<T>API surface for non-Compose consumerscompose-resourcesintegration forFormMessages- Field arrays / repeatable fields (
fieldArray<T>("phones")) - Input masking (format-as-you-type for credit cards / phone)
- Pre-built
cmp-form-testartifact with mock fixtures
See CONTRIBUTING.md.
Apache 2.0 — see LICENSE.