Skip to content

Model & schemas

How does one represent a form in KForm? Let’s take a look at an example. Imagine that you want to build a form that allows users to purchase bus tickets. A possible model for this form might look like the following:

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?)

As we can see, this form is represented by standard Kotlin classes with mutable properties which, apart from the Table data type (explained below) are not at all related to KForm.

So, how can KForm reason about these seemingly arbitrary types of data? This is what schemas are for: they offer KForm insight on the structure and types of your data. Schemas define the structure of your form and its validations: they provide the means for KForm to create, manipulate, and validate values of your form.

The following example shows how the schema of our bus ticket purchasing form can be defined (for now without any validations):

Schema of a form for purchasing bus tickets
val BusTripFormSchema = ClassSchema {
    BusTripForm::email { StringSchema() }
    BusTripForm::passengers {
        TableSchema {
            ClassSchema {
                Passenger::name { StringSchema() }
                Passenger::age { NullableSchema { IntSchema() } }
            }
        }
    }
}

This definition enables KForm to understand that our form is represented by a class BusTripForm containing two fields: email and passengers, of types String and Table<Passenger> respectively.

As you might notice, a form’s schema usually has a one-to-one correspondence with its model.

Supported data types

KForm natively supports many of Kotlin’s standard data types:

The following collections are currently supported:

  • Table: via TableSchema. Tables are KForm’s recommended data structure for representing collections since they provide stable identifiers for each element. E.g., the type Table<Int> can be represented as TableSchema { IntSchema() }.
  • List: via ListSchema. E.g., the type List<Int> can be represented as ListSchema { IntSchema() }.

For temporal-related data types, KForm supports:

Big numbers are supported via kt-math:

Files are supported via KForm’s own File datatype:

  • File: via FileSchema. This datatype is used by KForm to represent files to be uploaded in a form.

How to model your form

When creating the model of your form, you might wonder which data types to use. Should your field be typed as String or String?, Int or Int?? What data type should be used for collections?

Here are some guidelines:

  • The preferred data types often depend on what controls will be used to display/edit the form in your UI. For example, it is ofter better to represent a text field (e.g. <input type="text">) as a String rather than String?, otherwise there would be two possible representations for an “empty” text box: null and "".

    If, however, there’s a limited set of possible values for the field, and the form control becomes a <select> element or similar, then it might make more sense to use String? or a nullable enum.

  • Free-form numeric fields (e.g. <input type="number">) should typically be nullable, otherwise there won’t be a reasonable value for representing empty fields.

  • The previous rules allude to the following, which is worth mentioning: validation requirements don’t always dictate the data type that should be used. I.e., just because a field is mandatory, it doesn’t mean that it should be typed as non-nullable. It is more important to consider whether your data types are able to represent all possible states of your UI form controls.

    Instead, it might make sense to denote certain characteristics of your form values as validations rather than constraining the data type itself. For more information on validations, see Validations.

  • For collections over complex data, prefer Table<T> over List<T>, e.g. when modeling a table where rows can be added and removed, as shown in the previous BusTripForm example. Tables provide stable identifiers for each element, which means that paths to elements in the table remain stable even if the table is modified.

  • Use List<T> for small collections of primitive types, e.g. List<String> (e.g. when modeling a checkbox group or a <select multiple> element). Using Table<T> for this kind of collection is often also okay, but List<T> is typically enough.

Refactoring schemas

Schemas can be split amongst multiple variables and/or files. For example, the previous bus ticket form schema could be split as follows (with each part possibly placed in separate files):

Schema of a form for purchasing bus tickets, split per class
val PassengerSchema = ClassSchema {
    Passenger::name { StringSchema() }
    Passenger::age { NullableSchema { IntSchema() } }
}

val BusTripFormSchema = ClassSchema {
    BusTripForm::email { StringSchema() }
    BusTripForm::passengers { TableSchema { PassengerSchema } }
}

Composing schemas

Suppose there’s a need for modelling multiple forms which share some common fields. For example, consider the following forms for registering pets, which differ per type of pet and share some common fields via an interface:

Models of forms for registering pets
interface PetRegistrationForm {
    var name: String
}

data class DogRegistrationForm(override var name: String = "", var breed: String = "") :
    PetRegistrationForm

data class CatRegistrationForm(
    override var name: String = "",
    var livesIndoors: Boolean = false,
) : PetRegistrationForm

To share common schema definitions between forms, we can create an auxiliary function for defining the schema of the common interface fields, which is then called in the schema definition of each actual form:

Schemas of forms for registering pets
fun ClassSchemaBuilder<PetRegistrationForm>.petRegistrationFormSchema() {
    PetRegistrationForm::name { StringSchema(Required()) }
}

val DogRegistrationFormSchema = ClassSchema {
    petRegistrationFormSchema()
    DogRegistrationForm::breed { StringSchema(Required()) }
}

val CatRegistrationFormSchema = ClassSchema {
    petRegistrationFormSchema()
    CatRegistrationForm::livesIndoors { BooleanSchema() }
}