Formica is a lightweight, Kotlin Multiplatform-friendly form engine with:
- Schema-based validation (no annotations, no reflection magic)
- Reactive form state (per-field + whole form)
- Compose-first API (but UI-agnostic core)
- Full control over validation behavior
- Immutable data support
- Schema DSL (clean, composable validation)
- No annotations / no codegen
- Field-level + form-level validation
- Optional & conditional fields
- Reactive state (dirty, touched, errors, etc.)
- Compose integration
- Works in Kotlin Multiplatform
formica.core → runtime engine (state, validation, form logic)
formica.schema → schema DSL + validation rules
formica.compose → Compose integration layer
data class NewUser(
val email: String = "",
val name: String = "",
val address: String? = null,
val age: Int? = null,
val acceptedTerms: Boolean = false
)val NewUserSchema = schema<NewUser> {
field(
property = NewUser::email,
set = { data, value -> data.copy(email = value ?: "") }
) {
required("Email is required")
email("Invalid email")
}
field(
property = NewUser::name,
set = { data, value -> data.copy(name = value ?: "") }
) {
notBlank("Name is required")
maxLength(50)
}
field(
property = NewUser::address,
set = { data, value -> data.copy(address = value) },
clear = { data -> data.copy(address = null) }
) {
notBlank("Address cannot be blank if provided")
}
field(
property = NewUser::age,
set = { data, value -> data.copy(age = value) },
clear = { data -> data.copy(age = null) }
) {
min(18)
}
field(
property = NewUser::acceptedTerms,
set = { data, value -> data.copy(acceptedTerms = value ?: false) }
) {
checked("You must accept terms")
}
}@Composable
fun NewUserScreen() {
val formica = rememberFormica(
adapter = NewUserSchema,
initialData = NewUser()
)
Formica(formica) { form ->
Field(NewUser::email) { field ->
Column {
OutlinedTextField(
value = field.value ?: "",
onValueChange = field.onChange,
isError = field.showError
)
if (field.showError && field.error != null) {
Text(field.error)
}
}
}
Button(
onClick = form.submit,
enabled = form.canSubmit
) {
Text("Submit")
}
}
}No annotations. No reflection scanning.
Validation is explicitly defined:
required()
email()
min(18)Each field tracks:
value
error
dirty
touched
visible
enabledThe form tracks:
isDirty
isTouched
hasErrors
canSubmit
fieldErrors
formErrors
submitResultval nickname: String? = nullRules like notBlank():
- skip if
null - validate if not null
visibleWhen { it.hasSecondaryAddress }
enabledWhen { it.hasSecondaryAddress }Hidden/disabled fields are automatically ignored during validation.
validateOnChange(false)
field.validate()objectRule { data ->
if (data.name == data.email) {
ObjectRuleResult(
fieldErrors = mapOf("name" to "Name must not equal email")
)
} else {
ObjectRuleResult()
}
}- notEmpty()
- notBlank()
- email()
- strongPassword()
- url()
- minLength()
- maxLength()
- min()
- max()
- range()
- checked()
- required()
- validateOnlyIf()