Validation
Validation checks incoming data before your handler trusts it. You describe each field with a list of rules — short strings such as required, email, or min:18. Kona checks every field, collects human-readable error messages, and either hands you the validated data or throws a ValidationException. The default exception handler turns that exception into either a 422 JSON envelope (when the client expects JSON) or a redirect back with flashed errors and old input (typical HTML form posts), so a typical handler needs no error-handling code at all.
Validating a request
Section titled “Validating a request”Request.validate(rules) takes a Map<String, List<String>>: each field name maps to a list of rule strings.
router.post("/users") { request -> val input = request.validate( mapOf( "email" to listOf("required", "email"), "age" to listOf("nullable", "integer", "min:18"), "password" to listOf("required", "string", "min:8", "confirmed"), "role" to listOf("required", "in:admin,member"), ), ) Response.json(users.create(input), 201)}router.post("/users", request -> { Map<String, Object> input = request.validate(Map.of( "email", List.of("required", "email"), "age", List.of("nullable", "integer", "min:18"), "password", List.of("required", "string", "min:8", "confirmed"), "role", List.of("required", "in:admin,member") )); return Response.json(users.create(input), 201);});Pipe-joined rules inside a list element
Section titled “Pipe-joined rules inside a list element”Any list element may also contain several rules joined with |. Kona splits each element on | before running the rules, so these three declarations are identical:
"email" to listOf("required", "email")"email" to listOf("required|email")"email" to listOf("required|string", "email")"email", List.of("required", "email")"email", List.of("required|email")"email", List.of("required|string", "email")The one exception is regex:. An element that starts with regex: is never split, because | is meaningful inside a regular expression. Always give a regex rule its own list element: listOf("required", "regex:/^(draft|published)$/").
What you get back
Section titled “What you get back”On success, validate returns a Map<String, Any?> containing only the fields that have rules and are present in the input. Extra fields the client sent are dropped, and optional fields that were absent are not included. Use this map instead of reading the request again, so unvalidated input never leaks into your logic.
On failure, it throws ValidationException and your handler stops at that line.
Where the data comes from
Section titled “Where the data comes from”Request.validate validates request.data() — see Request & response:
- For JSON requests (
Content-Typecontainingapplication/json), values keep their JSON types: numbers, booleans,null, lists, and maps. Query-string values are included too, and body values win on conflict. - For form and other requests, every value is a
Stringtaken from the query string and the URL-encoded body.
This affects some rules. integer, numeric, and boolean accept suitable strings, so they work for form posts. array and list can only pass for JSON, because form values are always strings.
Validating any map with Validator
Section titled “Validating any map with Validator”When the data does not come straight from a request — a decoded message, a config block, a CSV row — build a Validator directly. Validator.make accepts either a Map<String, Any?> or a Request, with the same rule format.
val validator = Validator.make( mapOf("name" to "Ada", "tags" to listOf("math", "engines")), mapOf( "name" to listOf("required", "string", "max:50"), "tags" to listOf("array", "max:5"), ),)
if (validator.fails()) { println(validator.errors()) // Map<String, List<String>>} else { val clean = validator.validate() // same result as Request.validate}Validator validator = Validator.make( Map.of("name", "Ada", "tags", List.of("math", "engines")), Map.of( "name", List.of("required", "string", "max:50"), "tags", List.of("array", "max:5") ));
if (validator.fails()) { System.out.println(validator.errors()); // Map<String, List<String>>} else { Map<String, Object> clean = validator.validate();}passes() is the opposite of fails(). Each call re-runs the rules from scratch, so the result always reflects the current data. errors() returns the messages from the most recent run. validate() runs the rules and either returns the validated map or throws ValidationException.
How rules run
Section titled “How rules run”For each field, Kona works through the rules in the order you wrote them:
- Missing or blank fields skip most rules. If the field is absent, or is a string containing only whitespace, every rule except
requiredis skipped. Somin:3alone does not make a field mandatory — addrequiredwhen it must be present. requiredstops at the first failure. Ifrequiredfails, no other rules run for that field, so the client sees one clear message.nullableletsnullthrough. A field that is present with the valuenullnormally still runs its rules (and failsstring,integer, and so on). Withnullable, anullvalue skips them.- All other failures accumulate. Without
bail, every remaining rule runs and each failure adds a message. Withbailanywhere in the field’s rules, the first failure ends that field. - Unknown or malformed rules fail closed. A rule name Kona does not know adds
Unknown validation rule [name].A known rule with a bad parameter —min:abc,between:1, a regex with an invalid pattern or flag — addsInvalid validation rule [rule].These errors appear even when the field is missing, so a typo cannot silently disable a check.
Field names in messages have underscores replaced by spaces: first_name appears as “first name”.
Rule reference
Section titled “Rule reference”| Rule | Passes when | Error message |
|---|---|---|
required |
The field is present and not null, not a blank string, and not an empty list or map |
The email field is required. |
nullable |
Always; lets null skip the other rules |
— |
bail |
Always; stops the field at its first failure | — |
string |
The value is a string | The name field must be a string. |
integer |
A whole number, or a string of digits with an optional sign and no leading zeros ("42", "-7"), within the 64-bit range |
The age field must be an integer. |
numeric |
A number, or a numeric string ("3.14", "1e3", " 12 ") |
The price field must be a number. |
boolean |
One of true, false, 1, 0, "1", "0", "true", "false" |
The active field must be true or false. |
email |
A string shaped local@domain.tld with no spaces |
The email field must be a valid email address. |
url |
A string that parses as a URI with both a scheme and a host (https://example.com) |
The website field must be a valid URL. |
array |
A list or a map (JSON array or object) | The tags field must be an array. |
list |
A list (JSON array) | The tags field must be a list. |
min:n |
Size is at least n |
…must be at least 18. / …must be at least 8 characters. / …must have at least 1 items. |
max:n |
Size is at most n |
…must not be greater than 50 characters. |
size:n |
Size is exactly n |
…must be 4 characters. |
between:a,b |
Size is from a to b, inclusive |
…must be between 1 and 10. |
in:a,b,c |
The value (as text) is one of the listed options; for a list, every element must be | The selected role is invalid. |
not_in:a,b |
The value is not one of the listed options | The selected role is invalid. |
confirmed |
Equals the field named <field>_confirmation |
The password field confirmation does not match. |
confirmed:other |
Equals the field named other |
same as above |
same:other |
Equals the field named other |
The email field must match email again. (for same:email_again) |
different:other |
Differs from the field named other |
The email field must be different from email again. |
regex:/pattern/flags |
The string contains a match for the pattern | The code field format is invalid. |
not_regex:/pattern/flags |
The string does not contain a match | The code field format is invalid. |
file |
Value is a valid UploadedFile |
The photo field must be a file. |
image |
Valid upload with an image MIME type or image extension | The photo field must be an image. |
mimes:png,jpg |
Valid upload whose extension is listed | The photo field must be a file of type: png, jpg. |
mimetypes:image/png |
Valid upload whose Content-Type is listed |
The photo field must be a file of type: image/png. |
filled |
If the field is present, it must not be empty | The name field must have a value. |
present |
The field key must exist (even if empty) | The name field must be present. |
sometimes |
Skip the whole field when it is absent | — |
accepted |
yes, on, 1, or true (case-insensitive) |
The terms field must be accepted. |
declined |
no, off, 0, or false |
The marketing field must be declined. |
accepted_if:field,value |
Must be accepted when another field equals value |
…must be accepted when role is admin. |
declined_if:field,value |
Must be declined when another field equals value |
…must be declined when role is admin. |
prohibited |
Field must be missing or empty | …is prohibited. |
prohibited_if:field,value |
Prohibited when another field equals value |
…is prohibited when role is admin. |
prohibited_unless:field,value |
Prohibited unless another field equals value |
…is prohibited unless role is admin. |
prohibits:a,b |
When this field is present, listed fields must be empty | …prohibits a / b from being present. |
exclude |
Drop the field from validated output (always) | — |
exclude_if / exclude_unless / exclude_with / exclude_without |
Conditionally omit the field from validated output | — |
current_password |
Matches the authenticated user’s password via Validator.setCurrentPasswordChecker |
…is incorrect. |
alpha / alpha_num / alpha_dash |
Letters only / letters+digits / letters+digits+-+_ |
…must only contain letters. |
ascii |
ASCII characters only | …must only contain ASCII characters. |
uuid |
A UUID string | …must be a valid UUID. |
ulid |
A 26-character ULID string | …must be a valid ULID. |
mac_address |
MAC (aa:bb:…, aa-bb-…, or aabb.ccdd.eeff) |
…must be a valid MAC address. |
json |
A JSON object or array string | …must be a valid JSON string. |
ip / ipv4 / ipv6 |
IP address (IPv4 uses a dotted-quad check; IPv6 via the JDK) | …must be a valid IP address. |
timezone |
A JDK zone id such as UTC or America/New_York |
…must be a valid timezone. |
date |
Parses as an ISO local date / date-time | …must be a valid date. |
date_format:Y-m-d |
Matches the format (PHP-style Y m d H i s or a Java DateTimeFormatter pattern) |
…must match the format Y-m-d. |
after:… / before:… / after_or_equal:… / before_or_equal:… |
Date comparison against another field or a date string | …must be after start. |
starts_with:a,b / ends_with:a,b |
String prefix / suffix | …must start with one of: a, b. |
digits:n / digits_between:a,b |
Digit string of exact / ranged length | …must be 4 digits. |
lowercase / uppercase |
Entire string is lower / upper case | …must be lowercase. |
distinct |
Array / list with no duplicate values | …has duplicate values. |
required_if:field,value |
Required when another field equals value |
…is required when role is admin. |
required_unless:field,value |
Required unless another field equals value |
…is required unless role is admin. |
required_with:a,b |
Required when any listed field is non-empty | …is required when name is present. |
required_without:a,b |
Required when any listed field is empty / missing | …is required when name is not present. |
required_with_all:a,b |
Required when all listed fields are non-empty | …is required when a / b are present. |
required_without_all:a,b |
Required when all listed fields are empty | …is required when none of a / b are present. |
exists:table,column |
Row exists (needs an ExistenceChecker) |
…is invalid. |
unique:table,column[,exceptId] |
No conflicting row (needs an ExistenceChecker) |
…has already been taken. |
For UploadedFile values, min / max / size / between measure kilobytes (Laravel-compatible), not characters.
exists and unique
Section titled “exists and unique”These rules need a database probe. Register one with Validator.setExistenceChecker(checker) — typically from an Cadence-backed provider. Without a checker, the rule fails closed with a message that an ExistenceChecker is required, so a missing wire-up cannot silently pass.
How size is measured
Section titled “How size is measured”min, max, size, and between decide what “size” means from the value itself:
- Numbers (JSON numbers) are compared by value:
min:18means 18 or more. - Lists and maps are compared by element count:
max:5means at most five items. - Strings are compared by character count — unless the field also has
integerornumericand the string looks like a number, in which case it is compared by value. This is why"age" to listOf("integer", "min:18")works for a form fieldage=21.
confirmed and same
Section titled “confirmed and same”Both compare values with ordinary equality, so types matter: the JSON number 1 does not equal the string "1". For a password field, send password and password_confirmation with the same value.
Write the pattern between two identical delimiter characters (any non-alphanumeric character, usually /), followed by optional flags: i (ignore case), m (multiline), s (dot matches newlines), x (allow whitespace and comments), u (accepted, no effect). The rule passes when the pattern is found anywhere in the string, so anchor it with ^…$ to match the whole value. Non-string values fail.
"slug" to listOf("required", "regex:/^[a-z0-9-]+$/")"code" to listOf("required", "regex:/^[A-Z]{3}$/i")"slug", List.of("required", "regex:/^[a-z0-9-]+$/"),"code", List.of("required", "regex:/^[A-Z]{3}$/i")The error response
Section titled “The error response”ValidationException exposes errors (Java: getErrors()), a map from field name to a list of messages. Fields appear in the iteration order of your rules map: declaration order for Kotlin mapOf or a Java LinkedHashMap, unspecified for Java Map.of. Its message summarizes them: the first message, plus (and N more error) or (and N more errors) when there are others.
When the exception escapes your handler, the default exception handler chooses a response from request.expectsJson():
JSON clients (expectsJson() is true)
Section titled “JSON clients (expectsJson() is true)”Status 422 with:
{ "message": "The email field is required. (and 1 more error)", "errors": { "email": ["The email field is required."], "age": ["The age field must be at least 18."] }}expectsJson() is true when Accept prefers application/json without text/html, or when Content-Type is JSON.
Web / form clients (everything else)
Section titled “Web / form clients (everything else)”A 302 redirect to the previous URL (session _previous.url, else Referer, else /) with:
- flashed
errors(the validation map) - flashed
_old_inputfromrequest.all()
StartSession applies those pending flash bags when it saves. On the next GET, read them with request.old("email") / session.old("email") and session.get("errors"). See Session and Request & response.
This response passes through global middleware like any other. If you need a different shape, catch ValidationException in the handler, or install a custom ExceptionHandler — see Exceptions.
Form requests and controllers
Section titled “Form requests and controllers”Controllers can call validate(request, rules) or validate(request, formRequest). A FormRequest owns rules() and optional authorize(request) — see Controllers → Form requests. Unauthorized form requests throw HttpException(403) before rules run.
You can also call request.validate(rules) or Validator.make(request, rules).validate() directly from a handler.
Pitfalls and FAQ
Section titled “Pitfalls and FAQ”Why didn’t min:3 make the field required?
Absent/blank fields skip most rules. Add required.
Why does array fail on a form post?
Form values are strings. Send JSON for list/map rules.
Why did an unknown rule fail a missing field?
Unknown/malformed rules fail closed even when the field is absent, so typos cannot disable checks.
Do I need a database for exists / unique?
Yes — register Validator.setExistenceChecker(...). Without it, those rules fail closed.
What is not available today
Section titled “What is not available today”- Nested
dot.notationrule expansion for deep JSON objects as a first-class DSL beyond flat keys - Custom rule classes registered globally (use imperative checks in the handler for exotic cases)
- Automatic FormRequest injection from method signatures
Next steps
Section titled “Next steps”- Controller helpers in Controllers.
- Error envelopes in Exceptions.
- Flash redisplay in Request & response and Crema forms.