glimr/forms/validator
Validation Helpers
Sprinkling ad-hoc validation logic across controllers leads to inconsistent error messages and duplicated checks. This module provides a declarative rule system so validators are defined once per form, collected into a list, and executed together — producing a uniform error structure that templates can render without special-casing each field. The ctx type parameter lets custom rules reach into app state for checks like uniqueness without coupling the validator to a specific database or config.
Types
File uploads need different validation than text fields — size limits, extension checks, and presence checks all operate on the uploaded file metadata rather than a string value. A separate rule type keeps the type system from allowing string rules on file fields or vice versa.
pub type FileRule(ctx) {
FileRequired
FileMinSize(Int)
FileMaxSize(Int)
FileExtension(List(String))
FileCustom(
fn(String, wisp.UploadedFile, FormData, ctx) -> Result(
Nil,
String,
),
)
}
Constructors
-
FileRequired -
FileMinSize(Int) -
FileMaxSize(Int) -
FileExtension(List(String)) -
FileCustom( fn(String, wisp.UploadedFile, FormData, ctx) -> Result( Nil, String, ), )
Wraps form access in getter functions so validation rules and data extractors can read field values without coupling to wisp’s internal FormData type. Built once from the raw form data and passed through the entire validation chain, then reused by the data extractor after validation passes.
pub type FormData {
FormData(
get: fn(String) -> String,
get_file: fn(String) -> wisp.UploadedFile,
get_file_result: fn(String) -> Result(wisp.UploadedFile, Nil),
)
}
Constructors
-
FormData( get: fn(String) -> String, get_file: fn(String) -> wisp.UploadedFile, get_file_result: fn(String) -> Result(wisp.UploadedFile, Nil), )
Deferring execution lets validators be defined at module level as pure data — no form data or context needed until start() runs them. The opaque type prevents callers from pattern matching on internals, so the FieldRule vs FileFieldRule distinction stays an implementation detail.
pub opaque type Rule(ctx)
An enum of built-in rules covers the most common validation needs so validators stay declarative — no inline functions for simple checks like Required or MinLength. Exists and Unique handle the most common database checks directly. Both are case-insensitive by default (the right call for emails and usernames); use ExistsSensitive or UniqueSensitive when casing matters. The Custom variant is the escape hatch for anything more complex that needs the full app context.
pub type StringRule(ctx) {
Required
Email
MinLength(Int)
MaxLength(Int)
Min(Int)
Max(Int)
Numeric
Url
Digits(Int)
MinDigits(Int)
MaxDigits(Int)
Confirmed(String)
Regex(String)
RequiredIf(String, String)
RequiredUnless(String, String)
In(List(String))
NotIn(List(String))
Alpha
AlphaNumeric
StartsWith(String)
EndsWith(String)
Between(Int, Int)
Date
Uuid
Ip
Exists(db.DbPool, String)
ExistsSensitive(db.DbPool, String)
Unique(db.DbPool, String)
UniqueSensitive(db.DbPool, String)
Custom(
fn(String, String, FormData, ctx) -> Result(Nil, String),
)
}
Constructors
-
Required -
Email -
MinLength(Int) -
MaxLength(Int) -
Min(Int) -
Max(Int) -
Numeric -
Url -
Digits(Int) -
MinDigits(Int) -
MaxDigits(Int) -
Confirmed(String) -
Regex(String) -
RequiredIf(String, String) -
RequiredUnless(String, String) -
In(List(String)) -
NotIn(List(String)) -
Alpha -
AlphaNumeric -
StartsWith(String) -
EndsWith(String) -
Between(Int, Int) -
Date -
Uuid -
Ip -
Exists(db.DbPool, String) -
ExistsSensitive(db.DbPool, String) -
Unique(db.DbPool, String) -
UniqueSensitive(db.DbPool, String) -
Custom(fn(String, String, FormData, ctx) -> Result(Nil, String))
Grouping messages by field name lets templates render errors next to the relevant input rather than in a flat list at the top of the page. A list of messages per field means all failures show at once so users can fix everything in one submission instead of discovering errors one at a time.
pub type ValidationError {
ValidationError(name: String, messages: List(String))
}
Constructors
-
ValidationError(name: String, messages: List(String))
Values
pub fn for(
field_name: String,
rules: List(StringRule(ctx)),
) -> Rule(ctx)
Binding a field name to its rules in a single call keeps the validator definition readable — without this, callers would need to construct FieldRule variants directly and the opaque type would need to be exposed.
Example:
validator.for("email", [Required, Email])
pub fn for_file(
field_name: String,
rules: List(FileRule(ctx)),
) -> Rule(ctx)
Mirrors for() but accepts FileRule variants so the type system prevents accidentally mixing string rules with file fields. The same deferred execution model applies — rules are data until start() runs them.
Example:
validator.for_file("avatar", [FileRequired, FileMaxSize(2048)])
pub fn response(
errors: List(ValidationError),
) -> Result(Nil, List(ValidationError))
Exposed publicly so custom validation flows that bypass start() can still produce the same Result shape. Converting an empty error list to Ok(Nil) avoids forcing callers to check list length before deciding success or failure.
pub fn run(
ctx: context.Context(app),
rules: fn(context.Context(app)) -> List(
Rule(context.Context(app)),
),
data_fn: fn(FormData) -> typed_form,
redirect: response.Response(wisp.Body),
on_valid: fn(typed_form) -> response.Response(wisp.Body),
) -> response.Response(wisp.Body)
Controllers shouldn’t mix validation logic with request handling — that makes both harder to test and read. The use callback pattern lets controllers declare validation inline and only handle the happy path, while error responses are generated automatically based on the response format:
- HTML: flashes the first error per field into the session
as “errors.
” and redirects back to the form. - JSON: returns a 422 response with structured errors.
Example:
In your validator module (e.g.,
app/http/validators/contact.gleam):
pub type Data {
Data(name: String, email: String)
}
fn rules(_ctx) {
[
validator.for("name", [Required, MinLength(2)]),
validator.for("email", [Required, Email]),
]
}
fn data(data: FormData) -> Data {
Data(
name: data.get("name"),
email: data.get("email"),
)
}
pub fn validate(ctx: Context(App), next: fn(Data) -> Response) {
use validated <- validator.run(ctx, rules, data, redirect.back(ctx))
next(validated)
}
pub fn start(
pending: List(Rule(ctx)),
data: FormData,
ctx: ctx,
) -> Result(Nil, List(ValidationError))
Separated from run() so callers that need custom error handling can execute validation without the automatic 422 response. Collecting all errors before returning ensures users see every failure at once rather than fixing them one by one across multiple submissions.