Skip to content

Getting started

Rules

Validation is composed from ValueValidatorRules. Built-ins cover the common cases — Required, Email, MinLength(n), MaxLength(n), Phone(region) (see phone validation), HexColor, OneOf(allowed), typed numeric Min/Max/InRange, and more in com.chrisjenx.yakcov.strings / com.chrisjenx.yakcov.generic.

Apply any rule conditionally with onlyWhen: Required.onlyWhen(isBusiness) runs the rule only while the flag is true and passes otherwise.

ValueValidatorRule is a fun interface, so one-off rules are a lambda away:

// ValueValidatorRule is a fun interface — one-off rules can be inline SAM lambdas.
// Severity is graded: error/warning/info/success all flow through supportingText.
val NoPlusAddressing = ValueValidatorRule<String> { value ->
    if ("+" in value) RegularValidationResult.warning("Plus-addressing may break receipts")
    else RegularValidationResult.success()
}

See the built-in rules reference for the full catalog, and custom rules for reusable rule types and severity grading.

Two validator families

In-composition State-holder-owned (headless)
Create with rememberTextFieldValueValidator(rules) / rememberGenericValueValidator(state, rules) FieldValidator(initial, rules) — plain constructor
Choose when The form has no state holder; everything lives in the composable A ViewModel/presenter owns form state; you want submit() testable without UI

Both are snapshot-state backed, so composables that read them recompose automatically. The pattern guides (ViewModel, MVI, Circuit) show which to reach for in each architecture.

Showing errors

Validation severity and error display are separate channels: rules compute a severity on every change, but nothing is shown until the validator decides errors should be visible (focus lost, submit, …). That's why nothing flashes red while the user is still typing.

  • isError() / FieldValidationState.isError — true once errors are shown and severity is ERROR
  • supportingText() — a ready-made slot value for Material TextFields; renders the most severe message, or null when nothing should show. Pass supportingText(default = "…") to show a plain hint while the field has no message (e.g. a pristine field); the same default works on FieldValidationState.text()/supportingText()
  • Outcome severities: ERROR > WARNING > INFO > SUCCESS. Warnings and info messages flow through the same display channel without blocking submission.

validationConfig

For in-composition validators, the validationConfig modifier wires interaction behavior:

modifier = Modifier.validationConfig(
    validateOnFocusLost = true,       // start validating when focus leaves the field
    shakeOnInvalid = true,            // shake the field when validate() fails
    showErrorOnInteraction = false,   // defer isError until validate() is called
)

For headless FieldValidators there's no validationConfig modifier; call field.onFocusLost() from Modifier.onFocusLost { ... } to validate-on-focus-lost, state-holder owned. The shakeOnInvalid / showErrorOnInteraction knobs are specific to the in-composition validators — on the headless path, drive shake yourself (see MVI) and gate error display via the showError flag you thread through toFieldState.

Form-level submit

Validate everything at once; errors surface on every field and the call tells you whether to proceed:

if (listOf(email, password).validate()) { /* all valid — submit */ }

Works on both families: List<ValueValidator<*, *>>.validate() and List<FieldValidator<*>>.validate().

A complete screen

@Composable
fun BasicSignUpScreen(onSubmit: (email: String, password: String) -> Unit) {
    // No state holder: the validators live in the composable, remembered across
    // recompositions. Each one owns its TextFieldValue draft + validation state.
    val email = rememberTextFieldValueValidator(rules = listOf(Required, Email))
    val password = rememberTextFieldValueValidator(rules = listOf(Required, MinLength(8)))

    Column {
        with(email) {
            OutlinedTextField(
                value = value,
                onValueChange = ::onValueChange,
                label = { Text("Email") },
                isError = isError(),
                supportingText = supportingText(),
                singleLine = true,
                modifier = Modifier
                    .fillMaxWidth()
                    // Show errors when focus leaves the field; shake on invalid submit.
                    .validationConfig(validateOnFocusLost = true, shakeOnInvalid = true),
            )
        }
        with(password) {
            OutlinedTextField(
                value = value,
                onValueChange = ::onValueChange,
                label = { Text("Password") },
                isError = isError(),
                supportingText = supportingText(),
                singleLine = true,
                modifier = Modifier
                    .fillMaxWidth()
                    .validationConfig(validateOnFocusLost = true, shakeOnInvalid = true),
            )
        }
        Button(
            onClick = {
                // Form-level validate(): surfaces errors on every field, true when all pass.
                if (listOf(email, password).validate()) {
                    onSubmit(email.value.text, password.value.text)
                }
            },
            modifier = Modifier.fillMaxWidth(),
        ) { Text("Create account") }
    }
}