
Formik-style form-state engine with suspendable field operations, schema DSL and sync/async validation, nested/array fields, introspection, array helpers, and annotation-driven typed value code generation.
Kotlin Multiplatform port of Formik. Same form-state engine, written in Kotlin, with no opinion about your UI layer. Drop it into a coroutine, a Jetpack Compose screen, or a SwiftUI view and read state from a StateFlow.
val form = FormikController(
FormikConfig(
initialValues = mapOf<String, Any?>("email" to "", "password" to ""),
validate = { v -> buildErrors {
if ((v["email"] as String).isBlank()) put("email", "Required")
if ((v["password"] as String).length < 8) put("password", "Too short")
}},
onSubmit = { values, _ ->
api.login(values["email"] as String, values["password"] as String)
},
)
)
// setFieldValue / setFieldTouched / submit are suspend — call them from a coroutine
// (or use the fire-and-forget form.handleSubmit() outside one):
scope.launch {
form.setFieldValue("email", "user@example.com")
form.setFieldTouched("email", true)
form.submit()
}The plugins you'll need depend on what you're building. Apply only what's relevant — core users don't need anything beyond the Kotlin plugin.
plugins {
kotlin("jvm") version "2.0.21" // or kotlin("multiplatform") / kotlin("android")
// Only if you use @FormValues — the ksp(...) dep below requires this plugin.
id("com.google.devtools.ksp") version "2.0.21-1.0.27"
// Only if you use the Compose adapter. For pure Android Compose, the Android Compose
// plugin works too; the JetBrains one is what enables KMP shared composables.
id("org.jetbrains.compose") version "1.7.3"
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21"
}
repositories {
mavenCentral()
google()
}
dependencies {
implementation("io.github.apdelrahman1911:kformik:1.5.0")
// Optional
implementation("io.github.apdelrahman1911:kformik-compose:1.5.0") // Compose Multiplatform adapter
// KSP processor — needs BOTH compileOnly (for @FormValues import) and ksp (to run the processor)
compileOnly("io.github.apdelrahman1911:kformik-ksp:1.5.0")
ksp("io.github.apdelrahman1911:kformik-ksp:1.5.0")
}Targets: JVM 17+, Android (minSdk 21 for core / 24 for the Compose adapter), iOS (iosX64, iosArm64, iosSimulatorArm64).
The kformik-compose adapter is a Compose Multiplatform module — works in shared commonMain code on Android, Desktop JVM, and iOS. See the Compose section below.
Plain Map<String, Any?> forms or typed data class forms (via a ValuesUpdater, hand-rolled or KSP-generated).
Three validation flavors, mix and match:
validate: (V) -> FormikErrors callbacksuspend (V) -> FormikErrors)// Style A — plain validate callback. Just Kotlin. Good when the logic doesn't fit a rule shape
// or you want fine-grained control over conditionals.
val formA = FormikController(FormikConfig(
initialValues = mapOf("email" to "", "password" to ""),
validate = { v -> buildErrors {
if ((v["email"] as String).isBlank()) put("email", "Required")
if ((v["password"] as String).length < 8) put("password", "Too short")
}},
onSubmit = { /* … */ },
))
// Style B — schema DSL. Same rules, declared field by field. Introspectable; rules are data.
val schema = formSchema<Map<String, Any?>> {
field("email") {
required()
}
field("password") {
required()
minLength(8)
}
}
val formB = FormikController(FormikConfig(
initialValues = mapOf("email" to "", "password" to ""),
schemaValidator = schema,
onSubmit = { /* … */ },
))The schema is a regular Kotlin DSL — formSchema<V> { … } is a normal function with a lambda receiver, like buildString { } or apply { }. Inside it, field("name") { … } opens a builder for one path's rules. No code generation or compiler magic — every block is just a method call with a trailing lambda.
required, minLength, maxLength, email, pattern (regex), min, max (numeric), and custom for anything else. Full reference: docs/SCHEMA_VALIDATION.md.
Use custom. The rule lambda takes two arguments — the value at the field being declared (v) and
the whole form snapshot (all) — and returns a nullable error message (return null to pass,
a non-null String to fail):
val schema = formSchema<Map<String, Any?>> {
field("password") {
required()
minLength(8)
}
field("confirm") {
required()
// `v` is the current value at "confirm"; `all` is the whole form snapshot, so you can
// compare to any other path. Return null to pass, a message to fail.
custom("Doesn't match") { v, all -> if (v == all["password"]) null else "Doesn't match" }
}
}Field paths in the schema accept the same dot / bracket syntax as setFieldValue:
val schema = formSchema<Map<String, Any?>> {
field("user.email") { required(); email() }
field("user.address.city") { required() }
field("tags[0]") { minLength(3) }
}Plain validate = { … }
|
Schema DSL | |
|---|---|---|
| Best for | one-off forms; logic that doesn't fit a rule shape; complex conditional branching | reusable validation; multiple forms sharing rules; validation that needs to be inspected |
| Multi-error per field | hand-rolled with buildErrors
|
built-in via failFast = false
|
| Render required-field markers without running validation | manual bookkeeping |
schema.isRequired("email") directly |
| Cross-field checks | any Kotlin you want | custom { v, all -> … all["…"] } |
You can also combine them — set a schemaValidator AND a validate callback on the same FormikConfig; both run and their errors merge.
By default the schema stops at the first failing rule per field. Pass failFast = false to collect every failure:
val schema = formSchema<Map<String, Any?>>(failFast = false) {
field("password") {
required()
minLength(8)
custom("Must contain a digit") { v, _ -> if (v.toString().any(Char::isDigit)) null else "Must contain a digit" }
}
}
// validateAllField suspends — call it from a coroutine or suspend function:
val errors = schema.validateAllField(values, "password")
// ["Required", "Too short", "Must contain a digit"]The schema is data — you can ask it questions without running validation:
schema.isRequired("email") // true
schema.requiredFields() // {"email", "password", "confirm"}
schema.fieldInfo("password") // FormFieldInfo(path, rules, isRequired)Nested paths and array paths work everywhere a path string does:
form.setFieldValue("user.address.city", "Lagos")
form.setFieldValue("tags[1]", "gamma")
form.setFieldError("friends[0].name", "Required")Field arrays handle structural mutations and keep touched / errors aligned with the rows:
val friends = form.array("friends")
friends.push("aisha") // append, doesn't touch
friends.insert(1, "between")
friends.swap(0, 2)
friends.move(2, 0)
friends.remove(0)
friends.pop()
friends.replace(0, "REPLACED") // doesn't touchThe rest of Formik's surface is here too: validateOnChange / validateOnBlur / validateOnMount, dirty, isValid, submitCount, setStatus, submit-touches-all, async submit (suspending), per-field error overrides, and resetForm.
Annotate a data class:
@FormValues
data class LoginValues(val email: String, val password: String)The processor generates two siblings:
object LoginValuesPaths {
const val email = "email"
const val password = "password"
}
object LoginValuesUpdater : ValuesUpdater<LoginValues> { /* generated get/set/leafPaths */ }Which means no stringly-typed paths and no hand-rolled when (path) { … } boilerplate:
val form = FormikController(FormikConfig(
initialValues = LoginValues("", ""),
valuesUpdater = LoginValuesUpdater,
onSubmit = { v, _ -> api.login(v.email, v.password) },
))
form.setFieldValue(LoginValuesPaths.email, "user@example.com")
form.setFieldError(LoginValuesPaths.password, "Too short")Nested @FormValues data classes nest the path scope (UserValuesPaths.address.city). Lists, maps, sealed types, and generics aren't generated yet; for those, fall back to string paths and either hand-roll the ValuesUpdater or stay with Map<String, Any?>. Full walkthrough in docs/KSP_TYPED_PATHS.md.
KSP runs automatically whenever the Kotlin compiler runs — e.g. ./gradlew build, an Android Studio "Build → Make Project", or any save when Build project automatically is enabled in IntelliJ. The Paths / Updater files appear in build/generated/ksp/.../kotlin/, which the IDE already indexes as a source root.
If you'd rather have a single named task that regenerates the @FormValues outputs without running a full project build, paste this snippet into your build.gradle.kts:
tasks.register("generateKFormikTypedPaths") {
group = "kformik"
description = "Run KSP to generate @FormValues typed paths and ValuesUpdater objects (no full build)."
// tasks.matching is lazy and project-shape-agnostic — picks up whatever KSP tasks the active
// Kotlin / Android / KMP configuration registered: kspKotlin (JVM), kspDebugKotlin (Android),
// kspCommonMainKotlinMetadata + kspKotlinJvm/IosX64/… (KMP).
dependsOn(tasks.matching { it.name.startsWith("ksp") && it.name.contains("Kotlin") })
}The task shows up in IntelliJ / Android Studio's Gradle tool window under a kformik group. Run it from the IDE or ./gradlew generateKFormikTypedPaths to refresh only the generated outputs, skipping the rest of the build.
A future release (v1.6.0+) will ship a small
kformik-gradle-pluginthat auto-registers this task — so the snippet above becomes a one-lineplugins { id("io.github.apdelrahman1911.kformik") version "..." }. The snippet is the v1.5.0 path; both will work side by side once the plugin lands.
kformik-compose is a Compose Multiplatform module. The same rememberFormik(…) API works in shared commonMain code on:
| Target | Supported | Notes |
|---|---|---|
| Android (Jetpack Compose) | ✅ | uses AndroidX Compose runtime under the hood |
| Desktop JVM (Compose Multiplatform) | ✅ | works with compose-jb desktop projects |
| iOS (Compose Multiplatform) | ✅ |
iosX64, iosArm64, iosSimulatorArm64
|
| Web / WASM | ⏸ | not exposed yet (no wasmJs/js target on this module) |
Use it from any of those, including from commonMain:
// shared commonMain code:
@Composable
fun LoginScreen() {
val form = rememberFormik(
initialValues = mapOf<String, Any?>("email" to "", "password" to ""),
validate = { v -> buildErrors { /* … */ } },
onSubmit = { v, _ -> /* … */ },
)
val state by form.state
OutlinedTextField(
value = state.values["email"] as String,
onValueChange = { form.setFieldValue("email", it) },
isError = form.displayError("email") != null,
supportingText = { form.displayError("email")?.let { Text(it) } },
)
Button(onClick = { form.submit() }, enabled = !state.isSubmitting) {
Text("Sign in")
}
}The form-state code above compiles unchanged on Android, Desktop, and iOS. The only platform-specific layer is the choice of OutlinedTextField / Button widgets (Material 3 on Android + Desktop; Compose Multiplatform Material on iOS).
Working Android sample in sample-android-app/. More patterns in docs/COMPOSE_USAGE.md.
FormikIosBridge is a Swift-friendly facade around the same controller. Wrap it in an ObservableObject:
final class LoginViewModel: ObservableObject {
@Published var email = ""
@Published var emailError: String?
private let bridge: FormikIosBridge
init() {
bridge = FormikIosBridge.companion.create(
initialValues: ["email": "", "password": ""],
validate: { _ in [:] },
onSubmit: { _, _ in }
)
bridge.observe { [weak self] snap in
self?.email = snap.value(name: "email") as? String ?? ""
self?.emailError = snap.displayError(name: "email")
}
}
func onChange(_ v: String) { bridge.setFieldValue(name: "email", value: v, shouldValidate: nil) }
func submit() { bridge.submit() }
deinit { bridge.close() }
}More in docs/IOS_USAGE.md.
kformik/ core KMP library
kformik-compose/ Compose Multiplatform adapter (Android / Desktop / iOS)
kformik-ksp/ KSP processor for typed paths + ValuesUpdater (experimental)
sample-android-app/ Compose sample
examples/ 10 runnable JVM examples
docs/ topic-by-topic usage notes
./gradlew :examples:run -PrunExample=login
./gradlew :examples:run -PrunExample=schema
./gradlew :examples:run -PrunExample=fieldarray
./gradlew :examples:run -PrunExample=wizardOther example names: nested, async, typed, fieldlevel, dependent, debounced.
./gradlew :kformik:allTests :kformik:iosSimulatorArm64Test
./gradlew :kformik-compose:assembleRelease
./gradlew :sample-android-app:assembleDebug
./gradlew publishToMavenLocalMaven Central release process: docs/RELEASE_PROCESS.md.
@FormValues generation handles flat and nested data classes; List/Map properties are set by full-value replacement (no per-index typed accessor yet). Unsupported targets (non-data, sealed, abstract, generic classes) are reported with a clear error rather than miscompiled..github/workflows/ci.yml) runs JVM + Android + KSP tests, the iOS-simulator tests, the public-ABI check, and verifies publication wiring via publishToMavenLocal on every push/PR to main. A signed, secret-driven Maven Central release is not yet automated — releases are still run from a local machine (see docs/RELEASE_PROCESS.md).Port of Formik by Jared Palmer. Where Kformik diverges from upstream behavior, the difference is documented in docs/.
Apache-2.0. See LICENSE.
Kotlin Multiplatform port of Formik. Same form-state engine, written in Kotlin, with no opinion about your UI layer. Drop it into a coroutine, a Jetpack Compose screen, or a SwiftUI view and read state from a StateFlow.
val form = FormikController(
FormikConfig(
initialValues = mapOf<String, Any?>("email" to "", "password" to ""),
validate = { v -> buildErrors {
if ((v["email"] as String).isBlank()) put("email", "Required")
if ((v["password"] as String).length < 8) put("password", "Too short")
}},
onSubmit = { values, _ ->
api.login(values["email"] as String, values["password"] as String)
},
)
)
// setFieldValue / setFieldTouched / submit are suspend — call them from a coroutine
// (or use the fire-and-forget form.handleSubmit() outside one):
scope.launch {
form.setFieldValue("email", "user@example.com")
form.setFieldTouched("email", true)
form.submit()
}The plugins you'll need depend on what you're building. Apply only what's relevant — core users don't need anything beyond the Kotlin plugin.
plugins {
kotlin("jvm") version "2.0.21" // or kotlin("multiplatform") / kotlin("android")
// Only if you use @FormValues — the ksp(...) dep below requires this plugin.
id("com.google.devtools.ksp") version "2.0.21-1.0.27"
// Only if you use the Compose adapter. For pure Android Compose, the Android Compose
// plugin works too; the JetBrains one is what enables KMP shared composables.
id("org.jetbrains.compose") version "1.7.3"
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21"
}
repositories {
mavenCentral()
google()
}
dependencies {
implementation("io.github.apdelrahman1911:kformik:1.5.0")
// Optional
implementation("io.github.apdelrahman1911:kformik-compose:1.5.0") // Compose Multiplatform adapter
// KSP processor — needs BOTH compileOnly (for @FormValues import) and ksp (to run the processor)
compileOnly("io.github.apdelrahman1911:kformik-ksp:1.5.0")
ksp("io.github.apdelrahman1911:kformik-ksp:1.5.0")
}Targets: JVM 17+, Android (minSdk 21 for core / 24 for the Compose adapter), iOS (iosX64, iosArm64, iosSimulatorArm64).
The kformik-compose adapter is a Compose Multiplatform module — works in shared commonMain code on Android, Desktop JVM, and iOS. See the Compose section below.
Plain Map<String, Any?> forms or typed data class forms (via a ValuesUpdater, hand-rolled or KSP-generated).
Three validation flavors, mix and match:
validate: (V) -> FormikErrors callbacksuspend (V) -> FormikErrors)// Style A — plain validate callback. Just Kotlin. Good when the logic doesn't fit a rule shape
// or you want fine-grained control over conditionals.
val formA = FormikController(FormikConfig(
initialValues = mapOf("email" to "", "password" to ""),
validate = { v -> buildErrors {
if ((v["email"] as String).isBlank()) put("email", "Required")
if ((v["password"] as String).length < 8) put("password", "Too short")
}},
onSubmit = { /* … */ },
))
// Style B — schema DSL. Same rules, declared field by field. Introspectable; rules are data.
val schema = formSchema<Map<String, Any?>> {
field("email") {
required()
}
field("password") {
required()
minLength(8)
}
}
val formB = FormikController(FormikConfig(
initialValues = mapOf("email" to "", "password" to ""),
schemaValidator = schema,
onSubmit = { /* … */ },
))The schema is a regular Kotlin DSL — formSchema<V> { … } is a normal function with a lambda receiver, like buildString { } or apply { }. Inside it, field("name") { … } opens a builder for one path's rules. No code generation or compiler magic — every block is just a method call with a trailing lambda.
required, minLength, maxLength, email, pattern (regex), min, max (numeric), and custom for anything else. Full reference: docs/SCHEMA_VALIDATION.md.
Use custom. The rule lambda takes two arguments — the value at the field being declared (v) and
the whole form snapshot (all) — and returns a nullable error message (return null to pass,
a non-null String to fail):
val schema = formSchema<Map<String, Any?>> {
field("password") {
required()
minLength(8)
}
field("confirm") {
required()
// `v` is the current value at "confirm"; `all` is the whole form snapshot, so you can
// compare to any other path. Return null to pass, a message to fail.
custom("Doesn't match") { v, all -> if (v == all["password"]) null else "Doesn't match" }
}
}Field paths in the schema accept the same dot / bracket syntax as setFieldValue:
val schema = formSchema<Map<String, Any?>> {
field("user.email") { required(); email() }
field("user.address.city") { required() }
field("tags[0]") { minLength(3) }
}Plain validate = { … }
|
Schema DSL | |
|---|---|---|
| Best for | one-off forms; logic that doesn't fit a rule shape; complex conditional branching | reusable validation; multiple forms sharing rules; validation that needs to be inspected |
| Multi-error per field | hand-rolled with buildErrors
|
built-in via failFast = false
|
| Render required-field markers without running validation | manual bookkeeping |
schema.isRequired("email") directly |
| Cross-field checks | any Kotlin you want | custom { v, all -> … all["…"] } |
You can also combine them — set a schemaValidator AND a validate callback on the same FormikConfig; both run and their errors merge.
By default the schema stops at the first failing rule per field. Pass failFast = false to collect every failure:
val schema = formSchema<Map<String, Any?>>(failFast = false) {
field("password") {
required()
minLength(8)
custom("Must contain a digit") { v, _ -> if (v.toString().any(Char::isDigit)) null else "Must contain a digit" }
}
}
// validateAllField suspends — call it from a coroutine or suspend function:
val errors = schema.validateAllField(values, "password")
// ["Required", "Too short", "Must contain a digit"]The schema is data — you can ask it questions without running validation:
schema.isRequired("email") // true
schema.requiredFields() // {"email", "password", "confirm"}
schema.fieldInfo("password") // FormFieldInfo(path, rules, isRequired)Nested paths and array paths work everywhere a path string does:
form.setFieldValue("user.address.city", "Lagos")
form.setFieldValue("tags[1]", "gamma")
form.setFieldError("friends[0].name", "Required")Field arrays handle structural mutations and keep touched / errors aligned with the rows:
val friends = form.array("friends")
friends.push("aisha") // append, doesn't touch
friends.insert(1, "between")
friends.swap(0, 2)
friends.move(2, 0)
friends.remove(0)
friends.pop()
friends.replace(0, "REPLACED") // doesn't touchThe rest of Formik's surface is here too: validateOnChange / validateOnBlur / validateOnMount, dirty, isValid, submitCount, setStatus, submit-touches-all, async submit (suspending), per-field error overrides, and resetForm.
Annotate a data class:
@FormValues
data class LoginValues(val email: String, val password: String)The processor generates two siblings:
object LoginValuesPaths {
const val email = "email"
const val password = "password"
}
object LoginValuesUpdater : ValuesUpdater<LoginValues> { /* generated get/set/leafPaths */ }Which means no stringly-typed paths and no hand-rolled when (path) { … } boilerplate:
val form = FormikController(FormikConfig(
initialValues = LoginValues("", ""),
valuesUpdater = LoginValuesUpdater,
onSubmit = { v, _ -> api.login(v.email, v.password) },
))
form.setFieldValue(LoginValuesPaths.email, "user@example.com")
form.setFieldError(LoginValuesPaths.password, "Too short")Nested @FormValues data classes nest the path scope (UserValuesPaths.address.city). Lists, maps, sealed types, and generics aren't generated yet; for those, fall back to string paths and either hand-roll the ValuesUpdater or stay with Map<String, Any?>. Full walkthrough in docs/KSP_TYPED_PATHS.md.
KSP runs automatically whenever the Kotlin compiler runs — e.g. ./gradlew build, an Android Studio "Build → Make Project", or any save when Build project automatically is enabled in IntelliJ. The Paths / Updater files appear in build/generated/ksp/.../kotlin/, which the IDE already indexes as a source root.
If you'd rather have a single named task that regenerates the @FormValues outputs without running a full project build, paste this snippet into your build.gradle.kts:
tasks.register("generateKFormikTypedPaths") {
group = "kformik"
description = "Run KSP to generate @FormValues typed paths and ValuesUpdater objects (no full build)."
// tasks.matching is lazy and project-shape-agnostic — picks up whatever KSP tasks the active
// Kotlin / Android / KMP configuration registered: kspKotlin (JVM), kspDebugKotlin (Android),
// kspCommonMainKotlinMetadata + kspKotlinJvm/IosX64/… (KMP).
dependsOn(tasks.matching { it.name.startsWith("ksp") && it.name.contains("Kotlin") })
}The task shows up in IntelliJ / Android Studio's Gradle tool window under a kformik group. Run it from the IDE or ./gradlew generateKFormikTypedPaths to refresh only the generated outputs, skipping the rest of the build.
A future release (v1.6.0+) will ship a small
kformik-gradle-pluginthat auto-registers this task — so the snippet above becomes a one-lineplugins { id("io.github.apdelrahman1911.kformik") version "..." }. The snippet is the v1.5.0 path; both will work side by side once the plugin lands.
kformik-compose is a Compose Multiplatform module. The same rememberFormik(…) API works in shared commonMain code on:
| Target | Supported | Notes |
|---|---|---|
| Android (Jetpack Compose) | ✅ | uses AndroidX Compose runtime under the hood |
| Desktop JVM (Compose Multiplatform) | ✅ | works with compose-jb desktop projects |
| iOS (Compose Multiplatform) | ✅ |
iosX64, iosArm64, iosSimulatorArm64
|
| Web / WASM | ⏸ | not exposed yet (no wasmJs/js target on this module) |
Use it from any of those, including from commonMain:
// shared commonMain code:
@Composable
fun LoginScreen() {
val form = rememberFormik(
initialValues = mapOf<String, Any?>("email" to "", "password" to ""),
validate = { v -> buildErrors { /* … */ } },
onSubmit = { v, _ -> /* … */ },
)
val state by form.state
OutlinedTextField(
value = state.values["email"] as String,
onValueChange = { form.setFieldValue("email", it) },
isError = form.displayError("email") != null,
supportingText = { form.displayError("email")?.let { Text(it) } },
)
Button(onClick = { form.submit() }, enabled = !state.isSubmitting) {
Text("Sign in")
}
}The form-state code above compiles unchanged on Android, Desktop, and iOS. The only platform-specific layer is the choice of OutlinedTextField / Button widgets (Material 3 on Android + Desktop; Compose Multiplatform Material on iOS).
Working Android sample in sample-android-app/. More patterns in docs/COMPOSE_USAGE.md.
FormikIosBridge is a Swift-friendly facade around the same controller. Wrap it in an ObservableObject:
final class LoginViewModel: ObservableObject {
@Published var email = ""
@Published var emailError: String?
private let bridge: FormikIosBridge
init() {
bridge = FormikIosBridge.companion.create(
initialValues: ["email": "", "password": ""],
validate: { _ in [:] },
onSubmit: { _, _ in }
)
bridge.observe { [weak self] snap in
self?.email = snap.value(name: "email") as? String ?? ""
self?.emailError = snap.displayError(name: "email")
}
}
func onChange(_ v: String) { bridge.setFieldValue(name: "email", value: v, shouldValidate: nil) }
func submit() { bridge.submit() }
deinit { bridge.close() }
}More in docs/IOS_USAGE.md.
kformik/ core KMP library
kformik-compose/ Compose Multiplatform adapter (Android / Desktop / iOS)
kformik-ksp/ KSP processor for typed paths + ValuesUpdater (experimental)
sample-android-app/ Compose sample
examples/ 10 runnable JVM examples
docs/ topic-by-topic usage notes
./gradlew :examples:run -PrunExample=login
./gradlew :examples:run -PrunExample=schema
./gradlew :examples:run -PrunExample=fieldarray
./gradlew :examples:run -PrunExample=wizardOther example names: nested, async, typed, fieldlevel, dependent, debounced.
./gradlew :kformik:allTests :kformik:iosSimulatorArm64Test
./gradlew :kformik-compose:assembleRelease
./gradlew :sample-android-app:assembleDebug
./gradlew publishToMavenLocalMaven Central release process: docs/RELEASE_PROCESS.md.
@FormValues generation handles flat and nested data classes; List/Map properties are set by full-value replacement (no per-index typed accessor yet). Unsupported targets (non-data, sealed, abstract, generic classes) are reported with a clear error rather than miscompiled..github/workflows/ci.yml) runs JVM + Android + KSP tests, the iOS-simulator tests, the public-ABI check, and verifies publication wiring via publishToMavenLocal on every push/PR to main. A signed, secret-driven Maven Central release is not yet automated — releases are still run from a local machine (see docs/RELEASE_PROCESS.md).Port of Formik by Jared Palmer. Where Kformik diverges from upstream behavior, the difference is documented in docs/.
Apache-2.0. See LICENSE.