Skip to content

Validations

Validations can be provided to the schemas that define a form to validate its data. KForm offers a set of built-in validations, while allowing users to write their own. All validations must be instances of the abstract Validation<T> class.

Consider the following model of a form for purchasing bus tickets, originally presented in a previous section:

Model of a form for purchasing bus tickets
data class BusTripForm(var email: String, var passengers: Table<Passenger>)

data class Passenger(var name: String, var age: Int?)

Suppose that we want to ensure that all fields are filled in, the email is valid, the number of passengers is between 1 and 10, and that passenger ages are between 0 and 100. We can define a schema that does this using the following build-in validations:

Schema of a form for purchasing bus tickets
val BusTripFormSchema = ClassSchema {
    BusTripForm::email { StringSchema(Required(), MatchesEmail()) }
    BusTripForm::passengers {
        TableSchema(Required(), MaxSize(10)) {
            ClassSchema {
                Passenger::name { StringSchema(Required()) }
                Passenger::age { NullableSchema(Required()) { IntSchema(Min(0), Max(100)) } }
            }
        }
    }
}

Note

You might notice that, when types are nullable, we can add validations to either the NullableSchema itself, or to its “inner” schema (the IntSchema in the example above). Validations applied to the inner schema are guaranteed to only execute when the value being validated is not null.

On the other hand, validations applied to the NullableSchema will execute even when the value being validated is null.

Validating the following form instance (e.g. via a form validator):

BusTripForm(
    "nspencer@",
    tableOf(Passenger("Nelson Spencer", 32), Passenger("", null)),
)

Would produce errors matching the following list:

listOf(
    LocatedValidationError("/email", "emailPatternMismatch"),
    LocatedValidationError("/passengers/1/name", "valueMissing"),
    LocatedValidationError("/passengers/1/age", "valueMissing"),
)

Built-in validations

KForm offers the built-in validations listed below. For more information on each one, please consult the API reference of each validation itself. All built-in validations allow overriding the code and severity of the emitted issues:

  • Required: Applicable to values of any type. Ensures that a value is not missing, i.e. is not null, false, or empty (strings, collections, etc.).
  • NotEmpty: Applicable to values of any non-nullable type. Ensures that a value is not empty (strings, collections, files, etc.).
  • NotBlank: Applicable to values of type CharSequence (a supertype of String). Ensures that a string is not blank, according to CharSequence.isBlank.
  • MustEqual/MustNotEqual: Applicable to values of any type. Ensures that a value is equal to/different from the provided requiredValue/forbiddenValue argument.
  • OneOf/NotOneOf: Applicable to values of any type. Ensures that a value is/isn’t one of the provided allowedValues/disallowedValues.
  • Min/Max/ExclusiveMin/ExclusiveMax: Applicable to comparable values. Ensures that a value is within bounds in respect to the provided argument.
  • Length/MinLength/MaxLength: Applicable to values of type CharSequence (a supertype of String). Ensures that a string is exactly/at least/at most the provided length.
  • Size/MinSize/MaxSize: Applicable to values with a size (collections, tables, files, etc.). Ensures that a value has exactly/at least/at most the provided size.
  • Matches: Applicable to values of type String. Ensures that a string matches the provided pattern argument.
  • MatchesEmail: Applicable to values of type String. Ensures that a string matches an email address pattern according to the W3C HTML5 spec.
  • Scale: Applicable to values of type BigDecimal. Ensures that a big decimal value’s scale is the one provided as argument.
  • Accepts: Applicable to values of type File. Ensures that a file’s type is one of the provided acceptedFileTypes.
  • UniqueItems/UniqueItemsBy: Applicable to collections, arrays, and tables. Ensures that all items in the collection/array/table are unique.

Writing your first validation

Suppose that we want to prevent submissions of forms where the email has some unwanted domains. No built-in validations provide this logic, so we must write a custom validation for doing so. Such a validation could be implemented as follows:

Validation that forbids blacklisted email domains
object EmailDomainIsAllowed : Validation<String>() {
    private val BLACKLISTED_DOMAINS = setOf("example.com", "gmial.com", "test.com")

    override fun ValidationContext.validate() = flow {
        val domain = value.substringAfter('@')
        if (domain in BLACKLISTED_DOMAINS) {
            emit(ValidationError("disallowedDomain"))
        }
    }
}

It could be added to the schema of our email field with:

BusTripForm::email { StringSchema(/* … */ EmailDomainIsAllowed) }

Note

We’ve used an object singleton to define our EmailDomainIsAllowed validation because the validation doesn’t use any parameters. If it did, we could have used a class instead.

All built-in validations are classes because they can allow the customisation of their emitted issues’ code and severity.

EmailDomainIsAllowed extends Validation<String>, meaning that it can be applied to any string schema, and overrides the ValidationContext.validate method.

The ValidationContext is the context available while a validation is being executed: within it, value can be accessed to get the value being validated. Other properties such as the path and schema of the validated field can also be accessed.

The ValidationContext.validate method must return a Flow (an asynchronous stream) of validation issues. Use the flow builder function together with emit to indicate issues with the form data, which can be one of two types:

Validation issues always have a mandatory code which is used to identify the validation rule that was violated. Further properties such as a message or other details can be provided as a map of data to the issue.

Depending on other values

Suppose we want to add trip dates to our form. We can add a mandatory departure date and an optional return date (for when the trip is one-way only) via the following updated model and schema:

Model of a form for purchasing bus tickets, including trip dates
data class BusTripForm(
    var departureDate: LocalDate?,
    var returnDate: LocalDate?,
    // …
)

// …
Schema of a form for purchasing bus tickets, including trip dates
val BusTripFormSchema = ClassSchema {
    BusTripForm::departureDate { NullableSchema(Required()) { LocalDateSchema() } }
    BusTripForm::returnDate { NullableSchema { LocalDateSchema() } }
    // …
}

In this scenario, we should ensure that, when a return date is provided, it is not before the departure date. Such validation could be implemented as follows:

Validation that ensures that the return date isn't before the departure date
object ValidReturnDate : Validation<LocalDate>() {
    private val ValidationContext.departureDate: LocalDate? by dependency("/departureDate")

    override fun ValidationContext.validate() = flow {
        if (departureDate != null && value < departureDate!!) {
            emit(ValidationError("returnDateBeforeDeparture"))
        }
    }
}

And added to the returnDate’s field schema with:

BusTripForm::returnDate { NullableSchema { LocalDateSchema(ValidReturnDate) } }

Validations can have as many dependencies as needed. Dependencies are typically declared as delegated properties over a ValidationContext receiver. Meaning that the value of the dependency can be accessed while the validation is being executed with a context.

Dependencies can be declared via the following delegated properties:

  • dependency (throws an exception if no value exists at the specified path):

    private val ValidationContext.dependencyKey: Type by dependency(path)
    
  • dependencyOrNull (returns null if no value exists at the specified path):

    private val ValidationContext.dependencyKey: Type? by dependencyOrNull(path)
    

The path provided to dependency/dependencyOrNull is relative to the path of the field being validated. Instead of the absolute /departureDate, we could also have used ../departureDate, since our validation is being applied to a schema at /returnDate.

Dependency paths must not contain any wildcards, except for an optional trailing recursive wildcard (**) denoting that the validation should also depend on all descendants of the dependency, for example: /passengers/** (more on this in the next section).

The declared dependency types are validated at runtime once the validations are initialised within their schemas (e.g. in our example, if we’d declared the type of the departureDate dependency as Int?, an error would be thrown, at runtime, while initialising a form validator or form manager with the BusTripFormSchema).

Validation dependencies are declared explicitly so that they can be used as validation metadata, allowing the form manager to cache their result and only reevaluate them when values they depend on change.

Depending on descendants

Elaborating on the previous section, it is worth noting that all validations have an implicit dependency on ., i.e. on the value being validated, accessible as value within the validation context.

Suppose that we want to ensure that at least one of the passengers in the trip is an adult. We may try to implement a validation on the passengers’ table as follows:

Validation forbidding trips without adult passengers (incorrect version)
object HasAdultPassenger : Validation<Table<Passenger>>() {
    override fun ValidationContext.validate() = flow {
        if (value.size > 0) {
            for (passenger in value.values) {
                if (passenger.age != null && passenger.age!! >= 18) {
                    return@flow
                }
            }
            emit(ValidationError("noAdultPassengers"))
        }
    }
}

However, if we actually try to use this validation within a form manager, we will notice that the validation status of the passengers table will sometimes be wrong: in particular, editing the age of passengers will not cause the validation to be reevaluated; the validation will only rerun as passengers are added to or removed from the table. This happens because the validation has an implicit dependency on ., in this case, on /passengers, without also depending on its descendants.

To fix this, set the dependsOnDescendants property of the validation to true:

Validation forbidding trips without adult passengers (correct version)
object HasAdultPassenger : Validation<Table<Passenger>>() {
    override val dependsOnDescendants = true

    // …

This effectively changes the implicit dependency of the validation from . to ./**, meaning that the validation will not only depend on the value being validated, but also on all its descendants, being reevaluated whenever any of them change.

As previously hinted at, the same care must be taken when declaring explicit dependencies on other fields. Suppose that we want to add a validation to the /departureDate field which forbids Sunday trips from having any children. For the same reason, we would need to declare a dependency on /passengers/** rather than /passengers to ensure that the validation is reevaluated whenever the age of passengers changes.

Tip

You don’t need to depend on the descendants of a value (be it the field being currently validated or a field being depended upon) when:

  1. The value is represented by a schema which does not contain other schemas.
  2. You’re only interested in knowing if the value exists or not (i.e. if the check is something like value != null).
  3. The value is a collection (e.g. list or table) and you’re only interested in its size but not in its content (i.e. if the check is something like value.isEmpty() or value.size < 10).

Depending on external context

Besides depending on other values of the form, validations may also depend on data external to the form itself. Suppose that we want to forbid trips from being booked on days that have already been fully booked. Information on whether a day is already fully booked is “external” to the form, so it must be provided by other means: KForm calls this type of data “external contexts”.

External contexts are provided to KForm as a map of strings to arbitrary data: each key in this map represents the “name” of its external context.

Validations may declare dependencies on external contexts by referencing said names. Assuming that the set of fully booked days is provided as an external context named fullyBookedDays, we can implement the previously mentioned validation as follows:

Validation preventing trips from being booked on fully booked days
object NotFullyBooked : Validation<LocalDate?>() {
    private val ValidationContext.fullyBookedDays: Set<LocalDate> by externalContext()

    override fun ValidationContext.validate() = flow {
        if (value != null && value in fullyBookedDays) {
            emit(ValidationError("dayIsFullyBooked"))
        }
    }
}

External context dependencies can be declared via the following delegated properties (when an externalContextName is not provided as an argument, the external context name defaults to the name of the property itself):

  • externalContext (throws an exception if no external context was found with the provided name):

    private val ValidationContext.externalContextName: Type by externalContext()
    

    or:

    private val ValidationContext.customName: Type by externalContext(externalContextName)
    
  • externalContextOrNull (returns null if no external context was found with the provided name):

    private val ValidationContext.externalContextName: Type? by externalContextOrNull()
    

    or:

    private val ValidationContext.customName: Type? by externalContextOrNull(externalContextName)
    

Scoped validations

In some scenarios, we might want to present a form in multiple “modes”: forms that are mostly similar, but with some variations. For example, we might want to have slight variations for our example bus trip form depending on the country where the bus trip is taking place: in this scenario, we might want to enable or disable certain validations depending on the “scope” the validation is being evaluated under.

KForm provides ScopedValidations for this purpose. Scoped validations automatically depend on an external context (named "scope", by default) and only evaluate if the validation is allowed according to their specified scopes.

Suppose that our form may run in two different scopes, represented by the following enum:

enum class LocationScope {
    EU,
    US,
}

If our business logic mandates that passenger age is only mandatory for EU trips, we may scope the age field’s Required validation as follows:

ScopedValidation(Required(), ValidationScopes.OneOf(LocationScope.EU))

The above validation will only be evaluated if the value of the scope external context is LocationScope.EU.

Allowed validation scopes can be defined via one of:

Custom scoped validations may also be defined via extension. The following example defines a scoped validation which forbids trips from being booked on Sundays in the EU:

Scoped validation forbidding trips from being booked on Sundays in the EU
object ValidateSundayTrip : ScopedValidation<LocalDate?, LocationScope>() {
    override val scopes = ValidationScopes.OneOf(LocationScope.EU)

    override fun ValidationContext.scopedValidate() = flow {
        if (value != null && value!!.dayOfWeek == DayOfWeek.SUNDAY) {
            emit(ValidationError("invalidSundayTrip"))
        }
    }
}

The allowed scopes for this validation are defined via the scopes property and the validation logic is defined via the ValidationContext.scopedValidate method.

Custom scoped validations are otherwise identical to regular validations, supporting dependencies over values or external contexts.

Defining validations in Java

KForm supports validations defined only on the server-side (which are then provided to a form validator). If your server application is written in Java, you might have to define validations in Java instead of Kotlin.

Because it isn’t trivial to use certain Kotlin constructs or classes from Java (such as Flows), KForm provides a few helper classes to make it easier to define validations in Java:

For reference, we list below (roughly equivalent) Java implementations of all validations previously implemented on this page:

Validation that forbids blacklisted email domains (Java)
static class EmailDomainIsAllowed extends SyncValidation<String> {
    private static final Set<String> BLACKLISTED_DOMAINS =
            Set.of("example.com", "gmial.com", "test.com");

    @Override
    public void validate(ValidationContext ctx, List<ValidationIssue> issues) {
        String value = ctx.value();

        String domain = value.substring(value.indexOf('@') + 1);
        if (BLACKLISTED_DOMAINS.contains(domain)) {
            issues.add(new ValidationError("disallowedDomain"));
        }
    }
}
Validation that ensures that the return date isn't before the departure date (Java)
static class ValidReturnDate extends SyncValidation<LocalDate> {
    public ValidReturnDate() {
        addDependency("departureDate", "/departureDate");
    }

    @Override
    public void validate(ValidationContext ctx, List<ValidationIssue> issues) {
        LocalDate value = ctx.value();
        LocalDate departureDate = ctx.dependency("departureDate");

        if (departureDate != null && value.compareTo(departureDate) < 0) {
            issues.add(new ValidationError("returnDateBeforeDeparture"));
        }
    }
}
Validation forbidding trips without adult passengers (Java)
static class HasAdultPassenger extends SyncValidation<Table<Passenger>> {
    public HasAdultPassenger() {
        setDependsOnDescendants(true);
    }

    @Override
    public void validate(ValidationContext ctx, List<ValidationIssue> issues) {
        Table<Passenger> value = ctx.value();

        if (value.getSize() > 0) {
            for (Passenger passenger : value.getValues()) {
                if (passenger.getAge() != null && passenger.getAge() >= 18) {
                    return;
                }
            }
            issues.add(new ValidationError("noAdultPassengers"));
        }
    }
}
Validation preventing trips from being booked on fully booked days (Java)
static class NotFullyBooked extends SyncValidation<LocalDate> {
    public NotFullyBooked() {
        addExternalContextDependency("fullyBookedDays");
    }

    @Override
    public void validate(ValidationContext ctx, List<ValidationIssue> issues) {
        LocalDate value = ctx.value();
        Set<LocalDate> fullyBookedDays = ctx.externalContext("fullyBookedDays");

        if (value != null && fullyBookedDays.contains(value)) {
            issues.add(new ValidationError("dayIsFullyBooked"));
        }
    }
}
Scoped validation forbidding trips from being booked on Sundays in the EU (Java)
static class ValidateSundayTrip extends ScopedSyncValidation<LocalDate, LocationScope> {
    public ValidateSundayTrip() {
        setScopes(new ValidationScopes.OneOf<>(LocationScope.EU));
    }

    @Override
    public void scopedValidate(ValidationContext ctx, List<ValidationIssue> issues) {
        LocalDate value = ctx.value();

        if (value != null
                && ConvertersKt.toJavaLocalDate(value).getDayOfWeek() == DayOfWeek.SUNDAY) {
            issues.add(new ValidationError("invalidSundayTrip"));
        }
    }
}

The biggest differences from Kotlin-defined validations are the following:

  • Dependencies and other validation metadata should be defined in the constructor via:
  • The value being validated, as well as value of dependencies or external contexts needs to be obtained from the validation context more explicitly. This is typically done at the start of the validate method via: