Skip to content

Forms and Validation ​

Kumiki forms come in two styles: "bind individual inputs directly to a slot via bind," and "receive via ui.submit on a dedicated tile." The former is for reactive, incremental reflection; the latter is for transactional, committed submission.

Event selectors are always written as tile names (CSS attribute selectors are abolished). If you want to receive events directly on a built-in element (form, input, etc.), create a small tile that wraps that element.


5.1 Two-Way Binding of Individual Inputs ​

kumiki
slot draft : Text where len-lt(280) = ""

tile Compose = column(
                 textarea(bind=draft, placeholder="What's on your mind?") {rows: 3},
                 text(draft.length.show + "/280") {color: "muted"},
                 button(text="Post", onClick=post) {disabled: draft.is-empty})
  • bind=draft two-way binds the slot draft
  • User input updates the slot → the tile re-renders
  • Type and refinement are checked on each input

5.1.1 Elements That Support bind ​

ElementAcceptable types
inputText (type=text/email/password/url/search/tel, or any other field whose value is the text typed: number/date/datetime-local/time/month/week/color), Int/Float (type=number), Time (type=date/datetime-local)
textareaText
selectAny (same type as the value of options)
sliderInt / Float
check / switchBool
radioOne of a union type

An input bound to an Int, Float or Time reads its text the way Int.parse / Float.parse / Time.parse do (Standard Library §2.4.3) and writes the value it reads. The type is the bound position's base: the slot's type followed through a record field, or through an Option's or a Result's payload with .get, and unaliased, so type Qty = Int where positive and a nominal Int read as an Int. The reading is T.parse's exactly — ".5", which a number field can hold, is no Float, and "1e3" is a Float and no Int. Text that spells no value of the base ("", or "1.5" for an Int) is refused as a refinement violation is (§5.1.2): the slot keeps the last value it accepted, the field keeps what was typed, and error(field=…) names the reading it failed (§5.7.2). A Time is shown to a type="date" field as yyyy-MM-dd, and to a type="datetime-local" one as yyyy-MM-ddTHH:mm, on the local clock Time.parse reads a zone-less string on, so what the field shows reads back as the same day, or the same minute. A Text is written as typed, so it goes with any field whose value is the text typed; an Int, Float or Time goes only with the field kinds in the table, whose text it round-trips through. Any other pairing — a Time in a type="time" field, an Int in a text field, a Bool, or an Option bound whole rather than through .get — is reported by kumiki check (E0226). (type="datetime" is obsolete in HTML and renders as a text field.)

check / switch show the bound Bool and write the box's new state back when it is ticked; radio(group=…, bind=b, value=V) is selected exactly when b == V and writes V when it is chosen. All three go through the same write-back as input — the refinement refusal of §5.1.2 included — and it runs before the control's own onClick / onChange, so a handler reads the slot already written: moving check(value=b, onClick=toggle) to check(bind=b, onClick=toggle) inverts b twice, and the onClick has to go. With a bind=, the argument that decides the selection of an unbound control — value= on check / switch, selected= on radio — is not read (W0216); a radio's own value= is still what it writes. A bind of another type on check / switch, or a radio value that is not a value of the bound type, is reported by kumiki check (E0201, or E0216 for a variant of another union), and so is a bound radio with no value= to write (E0225).

5.1.2 Handling of refinement ​

For slot draft : Text where len-lt(280), when the input exceeds 280 characters the value is refused: the slot keeps the last value it accepted. There is one mode, and it is deliberately quiet — a half-typed value is expected, not a defect, so nothing is reported. A refinement violation on the assignment path (draft := … inside a reducer) is the opposite case — it discards the whole reducer batch and is reported, see batching.

The control keeps what was typed, so the field and the slot disagree until the field is edited to a value the slot accepts. error(field=draft) speaks for what the field shows (§5.7.1): while the field shows a value the refinement refused, the message is that value's. A reducer that rewrites the slot moves the field with it, and the message then follows the slot again. The same holds for text a bound Int, Float or Time cannot read at all (§5.1.1), on a slot with a refinement or without one: its message is the reading's, and comes before any refinement's. So the field shows a value the slot does not hold only together with the message saying why. Two refinements of that rule: the field in question is the one in the same view — with one app mounted into several hosts (runtime.md §10.9), each view's error(field=…) speaks for its own view's field, which in every other view still shows the slot's value; and during an IME composition the message is settled once, when the composition ends, not re-derived for every intermediate value the composition passes through.

kumiki
input(bind=draft)
error(field=draft)

A strict prop, which an earlier revision of this section specified as a second mode (take the refused value and turn a form-level valid flag false), is E0219. Nothing ever read that flag, and a slot that holds a value its own type refuses is what every other path to it is built to prevent.


5.2 Form Elements ​

When you want to commit-submit multiple inputs together, create a tile that wraps the form:

kumiki
slot loginEmail    : Text                = ""
slot loginPassword : Text     volatile   = ""
slot loginError    : Option(HttpError)   = None
slot loginPending  : Bool                = false

tile LoginForm
    = form(
        column(
          label(text="Email") {for: "loginEmail"},
          input(bind=loginEmail, type="email", id="loginEmail", required=true),
          label(text="Password") {for: "loginPw"},
          input(bind=loginPassword, type="password", id="loginPw", required=true,
                auto-complete="current-password"),
          when(loginError.is-some,
            text(loginError.get.message) {color: "danger"}),
          button(text="Log in", type="submit", loading=loginPending) {bg: "primary"}
        ) {gap: "sm"}
      )

reducer doLogin
    on=ui.submit(LoginForm)
    do= loginError := None
        loginPending := true
        emit login({email: loginEmail, password: loginPassword})

effect login    cap=http.post
                in={email: Text, password: Text}
                out=Result(SessionId, HttpError)
                policy=latest
                map-request={url: "/api/auth/login", body: Json($1), decode: Decoder.Json(SessionId)}

5.2.1 form props ​

propTypeMeaning
auto-completeBoolBrowser autocomplete
novalidateBoolSuppress HTML5 standard validation

Do not write onSubmit on the form itself. For the submit handler, write ui.submit(WrapperTile) in the reducer's on=, using the name of the tile that wraps the form.

5.2.2 Submit Behavior ​

  • If every slot a control inside the form binds passes validation, the ui.submit(WrapperTile) reducer is called
  • If even one fails, it is not called (individual error displays do appear)
  • What is judged is what each control shows — the judgement error(field=…) makes (§5.1.2), in its order: text an Int / Float / Time field shows that reads as no value of that type at all; else a value the refinement refused that a control inside the form still shows; else the slot's own value. So a pristine field whose declared default fails its refinement holds the form back (§5.6) — with or without an error(field=…) tile to say why, so a form holding such a field should carry one — and so does a field showing a refused edit or unreadable text: the reducer would otherwise read the slot's last accepted value, which is not what the field shows
  • Only slots bound inside the form count, and only the form's own controls speak for them. A slot the form's controls do not bind is not asked about, and a refused value shown by a control outside the form does not hold it back: the form's own control shows the slot's value, which is what the reducer reads. error(field=…) speaks for the whole view being rendered, so an error tile inside the form can show a message for that outside edit while the form submits
  • Fires by clicking button(type="submit"), or by pressing the Enter key in an input
  • A click reducer on that button — ui.click on it, one lifted onto it from a tile that wraps it, or an onClick= argument — is independent of the submit: clicking the button runs the click reducer and submits the form. Nothing a click reducer is bound to cancels the click, so type is the one thing here a click reducer can't replace: a click reducer never keeps a button inside a form from submitting it, and type="button" does. (A disabled or loading button does not submit either: it is disabled, so it is never activated.)
  • type is one of submit / button / reset, written through to the DOM verbatim, and is only meaningful inside a form. A button that does not write one keeps the HTML default, which is submit — so a button inside a form that is not meant to submit it must say type="button". A literal outside the three is E0201: an invalid type attribute resolves to submit, so the typo submits
  • A type="reset" button with a click reducer runs the reducer and resets the form, which puts the fields' DOM values back to their defaults but leaves the binded slots as they were — nothing listens for reset. To clear a form, write the slots in a reducer on a type="button" button

5.3 Common props for Input Elements ​

propTypeMeaning
bindslot nameTwo-way binding
valueexprOne-way value (instead of bind; updated in a reducer)
onChangereducer nameReducer called when the value changes
onInputreducer nameCalled on the input event (more frequent than onChange)
placeholderTextPlaceholder
disabledBoolDisable
readonlyBoolRead-only (a control that has no read-only state, such as a select, ignores it)
requiredBoolRequired
auto-focusBoolFocus on mount
auto-completeTextemail / current-password / new-password / off, etc.
idTextHTML id (referenced by a label's for)

5.3.1 By input type ​

kumiki
input(bind=email, type="email", auto-complete="email")
input(bind=password, type="password", auto-complete="current-password")
input(bind=age, type="number", min=0, max=120)
input(bind=birthday, type="date", min="1900-01-01")
input(bind=search, type="search")
input(bind=phone, type="tel", pattern="[0-9-]+")

5.4 Delivering Individual Input Events to a reducer ​

When bind is not enough (e.g., you want to run custom processing on every input), wrap that input in a dedicated small tile and receive ui.input / ui.change:

kumiki
slot pw  : Text                   = ""
slot pw2 : Text                   = ""
slot pwError : Option(Text)       = None

tile Pw1Input = input(bind=pw,  type="password")
tile Pw2Input = input(bind=pw2, type="password")

reducer validatePw
    on=ui.input(Pw2Input)
    do= pwError := if pw == pw2 then None else Some("Passwords don't match")

ui.input(TileName) receives events from the root element rendered by the TileName tile. For a composite tile, to target something other than the root element, split it into finer tiles.


5.5 select / radio ​

5.5.1 select ​

kumiki
type Filter = All | Active | Done
slot filter : Filter = All

tile FilterSelect = select(
                      bind=filter,
                      options=[
                        {label: "All",    value: All},
                        {label: "Active", value: Active},
                        {label: "Done",   value: Done}
                      ],
                      placeholder="Filter")

Three value/state Binding Forms ​

FormExamplePurpose
bind=<slot>bind=filterDirectly tied to a single slot. Updates the slot automatically on change
bind=<slot.field>bind=draft.priorityBind to a record's field path. Immutable update via _setPath
value=<expr>value=issues[id].statusRead-only display. Handle change yourself in a ui.change(SelectTile) reducer

In the value= form, on a change event the reducer subscribing to ui.change(<SelectTile>) is called, and you can receive the selected variant value via $event.value:

kumiki
tile StatusSelect = select(value=issues[iid].status,
                           options=statusOptions(),
                           placeholder="Status")

reducer updateStatus
    on=ui.change(StatusSelect)
    do= match routeIssueId(route) with
          | Some(iid) -> { issues[iid].status := $event.value;
                           issues[iid].updatedAt := now }
          | None      -> ()

Change Detection for input / textarea ​

In addition to updating a slot via bind=, input/textarea can also fire via the ui.change(InputTile) / ui.input(InputTile) reducers. $event.value holds the current text.

5.5.2 radio ​

radio has a group prop for grouping (corresponding to CSS's name attribute):

kumiki
tile FilterRadioAll    = radio(group="filter", value=All,    selected=(filter == All))    {label: "All"}
tile FilterRadioActive = radio(group="filter", value=Active, selected=(filter == Active)) {label: "Active"}
tile FilterRadioDone   = radio(group="filter", value=Done,   selected=(filter == Done))   {label: "Done"}

tile FilterRadioGroup = column(FilterRadioAll, FilterRadioActive, FilterRadioDone)

reducer setFilterAll    on=ui.change(FilterRadioAll)    do= filter := All
reducer setFilterActive on=ui.change(FilterRadioActive) do= filter := Active
reducer setFilterDone   on=ui.change(FilterRadioDone)   do= filter := Done

Alternatively, if you receive a union type directly via bind, a single reducer is unnecessary:

kumiki
tile FilterRadioGroup = column(
                          radio(group="filter", bind=filter, value=All)    {label: "All"},
                          radio(group="filter", bind=filter, value=Active) {label: "Active"},
                          radio(group="filter", bind=filter, value=Done)   {label: "Done"})

This is the recommended approach.


5.6 Validation Strategy ​

Kumiki validation has three layers:

LayerResponsible forExample
TypeCompilerYou cannot put a string into slot age : Int
refinementRuntimeage : Int where between(0, 120)
Cross-formreducer / fn"password and password-confirm match"

The refinement layer covers every predicate §1.3.3 registers, including the ones the standard library's domain types are declared with — Email, Url, Uuid and HttpStatus are refined nominals (Standard Library §2.1.3), so a slot declared with one is checked exactly as a slot written Text where email is. A predicate the toolchain cannot lower to a check is a build error (E0803), never a check that passes.

The two write paths differ in how loud they are, and a bind is further judged only at the path it writes (below). An assignment is judged on the whole value, even one that writes a single field (signup.name := …):

  • Assignment (age := … in a reducer) discards the whole batch and reports it — no slot written, no effect emitted (Runtime §10.3.3).
  • bind refuses the value for that field alone and reports nothing, because a half-typed value is expected rather than a defect. The field keeps showing it, and error(field=…) renders its message (§5.1.2).

Neither gates the declared default: slot email : Email = "" starts out holding a value its own refinement rejects, which is what puts a message on a pristine form (§5.7.1).

A bind into part of a slot — input(bind=form.age), input(bind=draft.nick.get) — is judged at the path it writes: the predicates along that path, on the slot's own type included, and every one below where it ends. A predicate on a sibling is not on that path, so a sibling that fails does not refuse the write. That is what lets a record whose default fails several fields be filled in any order. The field that shows a refused value is laid over the slot as it is now when error(field=…) judges it, so a sibling written afterwards does not bring back a message that field no longer deserves. A control bound to the whole slot shows the value the fields bound into it sit in, so when both show refused values the slot's is laid first and the fields' over it.

5.6.1 Cross-Form Example ​

kumiki
slot pw  : Text  = ""
slot pw2 : Text  = ""
slot pwError : Option(Text) = None

fn validatePassword(p1: Text, p2: Text) -> Option(Text)
   = if p1 == p2 then None else Some("Passwords don't match")

tile Pw2Input = input(bind=pw2, type="password")

reducer onPw2Change
    on=ui.input(Pw2Input)
    do= pwError := validatePassword(pw, pw2)

tile SignupForm
    = form(
        column(
          input(bind=pw, type="password"),
          Pw2Input,
          when(pwError.is-some,
            text(pwError.get) {color: "danger"}),
          button(text="Sign up", type="submit", disabled=pwError.is-some)))

reducer doSignup on=ui.submit(SignupForm) do= ...

5.7 Error Display ​

5.7.1 refinement Violation of an Individual Field ​

Display via the error element:

kumiki
input(bind=email, type="email")
error(field=email)

error(field=...) is a built-in tile that renders the target field's current validation error: it reads the predicate off the slot and renders the message for it whenever the value the field shows fails, and nothing otherwise. That is the slot's current value, except while a bound control shows a value the refinement refused (§5.1.2), which is judged instead. A slot whose type carries no refinement therefore has no message for any value — which is a statement about the slot, not about the value in it. What it can still show is the one message that is not about a value: while a bound input shows text that does not read as the slot's Int, Float or Time at all (§5.1.1), the message is that reading's (int / float / time below), on any slot and before any refinement — "1.5" into an Int where between(0, 120) is not a whole number, not a number out of range.

A type carrying several predicates (§1.3.1) renders the message of the first one the current value fails, in the order §1.3.1 gives them. On slot draft : Text where nonempty where len-lt(7) = "" a pristine field reads "Required", not a bound the empty value is well inside.

5.7.2 Standard Messages ​

PredicateDefault
email"Invalid email format"
url"Invalid URL"
uuid"Invalid identifier"
nonempty"Required"
len-eq(N)"Must be exactly N characters"
len-lt(N) / len-gt(N)"Must be less than / more than N characters"
between(A, B)"Must be between A and B"
positive / negative"Must be positive" / "Must be negative"
regex(P)"Does not match pattern"
one-of(...)"Must be one of: ..."
int (text that is no Int, §5.1.1)"Must be a whole number"
float (text that is no Float)"Must be a number"
time (text that is no Time)"Must be a date"

Override custom messages via theme.errors, keyed by the table's first column — int / float / time included:

kumiki
theme MyTheme = {
    ...,
    errors: {
        email: "Please enter a valid email address",
        nonempty: "This field is required"
    }
}

5.8 UI During Submission ​

kumiki
slot loginPending : Bool = false

reducer doLogin
    on=ui.submit(LoginForm)
    do= loginPending := true
        emit login({email: loginEmail, password: loginPassword})

reducer loginOk
    on=login.ok($s, _)
    do= loginPending := false
        session := Some($s)
        emit navigate({path: "/app", params: {}, query: {}})

reducer loginErr
    on=login.err($e, _)
    do= loginPending := false
        loginError := Some($e)

button.loading automatically shows a spinner and disables the button.


5.9 Multi-step Forms ​

kumiki
type Step = Account | Profile | Confirm

slot step : Step = Account
slot acct : {email: Text, pw: Text}     = {email: "", pw: ""}
slot prof : {name: Text, bio: Text}     = {name: "", bio: ""}

fn nextStep(s: Step) -> Step = match s with | Account -> Profile | Profile -> Confirm | Confirm -> Confirm
fn prevStep(s: Step) -> Step = match s with | Profile -> Account | Confirm -> Profile | Account -> Account

tile NextBtn = button(text="Next") {bg: "primary"}
tile PrevBtn = button(text="Back") {variant: "ghost"}

reducer next on=ui.click(NextBtn) do= step := nextStep(step)
reducer prev on=ui.click(PrevBtn) do= step := prevStep(step)

tile Wizard = column(
                ProgressIndicator(step),
                match step with
                  | Account -> AcctStep
                  | Profile -> ProfStep
                  | Confirm -> ConfirmStep,
                row(PrevBtn, NextBtn) {gap: "sm"})

Splitting each step into an independent tile makes it easier for the AI to track as well.


5.10 File Upload ​

kumiki
slot avatar : Option(File) = None

tile AvatarPicker = input(type="file", accept="image/*")

reducer pickFile
    on=ui.change(AvatarPicker)
    do= avatar := $event.files.head

tile UploadBtn = button(text="Upload")

reducer upload
    on=ui.click(UploadBtn)
    do= match avatar with
            | Some(f) -> emit uploadFile({file: f})
            | None    -> ()

tile AvatarUpload = column(
                      AvatarPicker,
                      when(avatar.is-some,
                        image(src=file-url(avatar.get)) {w: 100, h: 100, aspect: "1/1"}),
                      UploadBtn)

effect uploadFile  cap=http.post
                   in={file: File}
                   out=Result({url: Url}, HttpError)
                   policy=latest
                   map-request={url: "/api/upload", body: Multipart({file: FileV($1.file)}), decode: Decoder.Json({url: Url})}

file-url(file) is a built-in equivalent to URL.createObjectURL (with automatic release).

accept and multiple are only valid when type="file". Used on any other type — or with type omitted (which defaults to "text") — the typecheck rejects them with E0206.


5.11 Design Decision Record ​

DecisionRationale
Tie slots directly via bindEliminates the dual controlled/uncontrolled model
Event selectors are tile names onlyRemoves dependence on CSS knowledge; consistent with Kumiki's layer separation
Bind the form's submit handler to the wrapper tile, not the form itself"Which reducer receives it" is visible in one place in the tile tree
Type-level validation via refinement"If the type passes, the value is valid"
Centralize error messages in the themei18n and consistency
Express multi-step via slotsAvoids adding a dedicated wizard DSL
Files are a File type rather than BytesStructures size, MIME, and name
radio's group propWraps the HTML name attribute, kept self-contained within Kumiki

5.12 Next ​