
UI-agnostic form validation toolkit standardizing immutable, testable input states; supports custom and dynamic validations, aggregated form validity, and submission lifecycle handling.
Formk is a pure Kotlin, UI-agnostic library for standardizing form validation across your Kotlin Multiplatform (KMP) projects (Android, iOS via Compose Multiplatform, Desktop, Web). Strongly inspired by the famous formz package in the Flutter ecosystem.
Available on Maven Central. Add the dependency to your Kotlin Multiplatform build.gradle.kts:
sourceSets {
commonMain.dependencies {
implementation("io.github.fadibouteraa:formk-core:1.0.0")
}
}The Kotlin ecosystem often sees developers mixing UI logic (Jetpack Compose states) with business validation rules, leading to spaghetti code that is hard to test and impossible to share across platforms.
The Formk Approach:
android.content.Context or androidx.compose. Inherently ready for any KMP target.The foundational class. It encapsulates the field's value, whether it has been touched (isPure), and exposes its error.
abstract class FormkInput<T, E>(
val value: T,
val isPure: Boolean = true
) {
abstract fun validator(value: T): E?
open val isValid: Boolean get() = validator(value) == null
val isNotValid: Boolean get() = !isValid
open val error: E? get() = validator(value)
val displayError: E? get() = if (isPure) null else error
}While you can use DynamicInput, you can also create highly specialized inputs for your domain by extending FormkInput. Here is an example of a custom PasswordInput:
enum class PasswordValidationError { Empty, TooShort }
class PasswordInput private constructor(
value: String,
isPure: Boolean
) : FormkInput<String, PasswordValidationError>(value, isPure) {
constructor() : this("", isPure = true)
fun dirty(newValue: String) = PasswordInput(newValue, isPure = false)
fun markDirty() = PasswordInput(value, isPure = false)
override fun validator(value: String): PasswordValidationError? = when {
value.isBlank() -> PasswordValidationError.Empty
value.length < 6 -> PasswordValidationError.TooShort
else -> null
}
}Built-in into formk-core, it provides dynamic, configuration-driven validation using FieldValidationConfig.
val emailConfig = FieldValidationConfig(
pattern = "^[A-Za-z0-9+_.-]+@(.+)$",
required = true
)
val input = DynamicInput(emailConfig).dirty("test@example.com")
println(input.isValid) // trueAn interface applied to your screen's UiState. It automatically computes the global validity of the entire form by aggregating all inputs.
interface FormkMixin {
val inputs: List<FormkInput<*, *>>
val isValid: Boolean get() = inputs.all { it.isValid }
}A sealed class representing the lifecycle of the form submission, essential for displaying loaders or global error messages (Initial, InProgress, Success, Failure).
Use the provided DynamicInput or create your own custom inputs extending FormkInput.
data class LoginState(
val email: DynamicInput = DynamicInput(FieldValidationConfig(required = true)),
val password: DynamicInput = DynamicInput(FieldValidationConfig(minLength = 6)),
val status: FormSubmissionStatus = FormSubmissionStatus.Initial
) : FormkMixin {
override val inputs = listOf(email, password)
}Handle user input and state mutations easily.
class LoginViewModel {
private val _state = MutableStateFlow(LoginState())
val state = _state.asStateFlow()
fun onEmailChanged(newEmail: String) {
_state.update { it.copy(email = it.email.dirty(newEmail)) }
}
fun onSubmit() {
// Mark all fields as dirty to trigger UI errors on untouched fields
_state.update {
it.copy(
email = it.email.markDirty(),
password = it.password.markDirty()
)
}
// Block submission if any field is invalid
if (_state.value.isNotValid) return
// Process submission
_state.update { it.copy(status = FormSubmissionStatus.InProgress) }
// ... API Call ...
}
}Perfect synchronization without messy if/else checks.
TextField(
value = state.email.value,
isError = state.email.displayError != null, // Only shows error if field is dirty (touched)
enabled = !state.status.isInProgress,
onValueChange = { viewModel.onEmailChanged(it) }
)
Button(
enabled = state.isValid && !state.status.isInProgress,
onClick = { viewModel.onSubmit() }
) {
if (state.status.isInProgress) CircularProgressIndicator()
else Text("Login")
}Formk is a pure Kotlin, UI-agnostic library for standardizing form validation across your Kotlin Multiplatform (KMP) projects (Android, iOS via Compose Multiplatform, Desktop, Web). Strongly inspired by the famous formz package in the Flutter ecosystem.
Available on Maven Central. Add the dependency to your Kotlin Multiplatform build.gradle.kts:
sourceSets {
commonMain.dependencies {
implementation("io.github.fadibouteraa:formk-core:1.0.0")
}
}The Kotlin ecosystem often sees developers mixing UI logic (Jetpack Compose states) with business validation rules, leading to spaghetti code that is hard to test and impossible to share across platforms.
The Formk Approach:
android.content.Context or androidx.compose. Inherently ready for any KMP target.The foundational class. It encapsulates the field's value, whether it has been touched (isPure), and exposes its error.
abstract class FormkInput<T, E>(
val value: T,
val isPure: Boolean = true
) {
abstract fun validator(value: T): E?
open val isValid: Boolean get() = validator(value) == null
val isNotValid: Boolean get() = !isValid
open val error: E? get() = validator(value)
val displayError: E? get() = if (isPure) null else error
}While you can use DynamicInput, you can also create highly specialized inputs for your domain by extending FormkInput. Here is an example of a custom PasswordInput:
enum class PasswordValidationError { Empty, TooShort }
class PasswordInput private constructor(
value: String,
isPure: Boolean
) : FormkInput<String, PasswordValidationError>(value, isPure) {
constructor() : this("", isPure = true)
fun dirty(newValue: String) = PasswordInput(newValue, isPure = false)
fun markDirty() = PasswordInput(value, isPure = false)
override fun validator(value: String): PasswordValidationError? = when {
value.isBlank() -> PasswordValidationError.Empty
value.length < 6 -> PasswordValidationError.TooShort
else -> null
}
}Built-in into formk-core, it provides dynamic, configuration-driven validation using FieldValidationConfig.
val emailConfig = FieldValidationConfig(
pattern = "^[A-Za-z0-9+_.-]+@(.+)$",
required = true
)
val input = DynamicInput(emailConfig).dirty("test@example.com")
println(input.isValid) // trueAn interface applied to your screen's UiState. It automatically computes the global validity of the entire form by aggregating all inputs.
interface FormkMixin {
val inputs: List<FormkInput<*, *>>
val isValid: Boolean get() = inputs.all { it.isValid }
}A sealed class representing the lifecycle of the form submission, essential for displaying loaders or global error messages (Initial, InProgress, Success, Failure).
Use the provided DynamicInput or create your own custom inputs extending FormkInput.
data class LoginState(
val email: DynamicInput = DynamicInput(FieldValidationConfig(required = true)),
val password: DynamicInput = DynamicInput(FieldValidationConfig(minLength = 6)),
val status: FormSubmissionStatus = FormSubmissionStatus.Initial
) : FormkMixin {
override val inputs = listOf(email, password)
}Handle user input and state mutations easily.
class LoginViewModel {
private val _state = MutableStateFlow(LoginState())
val state = _state.asStateFlow()
fun onEmailChanged(newEmail: String) {
_state.update { it.copy(email = it.email.dirty(newEmail)) }
}
fun onSubmit() {
// Mark all fields as dirty to trigger UI errors on untouched fields
_state.update {
it.copy(
email = it.email.markDirty(),
password = it.password.markDirty()
)
}
// Block submission if any field is invalid
if (_state.value.isNotValid) return
// Process submission
_state.update { it.copy(status = FormSubmissionStatus.InProgress) }
// ... API Call ...
}
}Perfect synchronization without messy if/else checks.
TextField(
value = state.email.value,
isError = state.email.displayError != null, // Only shows error if field is dirty (touched)
enabled = !state.status.isInProgress,
onValueChange = { viewModel.onEmailChanged(it) }
)
Button(
enabled = state.isValid && !state.status.isInProgress,
onClick = { viewModel.onSubmit() }
) {
if (state.status.isInProgress) CircularProgressIndicator()
else Text("Login")
}