Formica
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
Features
- 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
Modules
formica.core → runtime engine (state, validation, form logic)
formica.schema → schema DSL + validation rules
formica.compose → Compose integration layer
Quick Start
1. Define your model
data class NewUser(
val email: String = "",
val name: String = "",
val address: String? = null,
val age: Int? = null,
val acceptedTerms: Boolean = false
)
2. Create a schema
3. Use in Compose
@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")
}
}
}
Core Concepts
Schema-first validation
No annotations. No reflection scanning.
Validation is explicitly defined:
required()
email()
min(18)
Reactive form state
Each field tracks:
value
error
dirty
touched
visible
enabled
The form tracks:
isDirty
isTouched
hasErrors
canSubmit
fieldErrors
formErrors
submitResult
Optional fields
val nickname: String? = null
Rules like notBlank():
- skip if
null
- validate if not null
Conditional fields
visibleWhen { it.hasSecondaryAddress }
enabledWhen { it.hasSecondaryAddress }
Hidden/disabled fields are automatically ignored during validation.
Manual validation
validateOnChange(false)
field.validate()
Object-level validation
objectRule { data ->
if (data.name == data.email) {
ObjectRuleResult(
fieldErrors = mapOf("name" to "Name must not equal email")
)
} else {
ObjectRuleResult()
}
}
Built-in Rules
Strings
- notEmpty()
- notBlank()
- email()
- strongPassword()
- url()
- minLength()
- maxLength()
Numbers
Boolean
Generic
- required()
- validateOnlyIf()