Forms and validation
What it is
Section titled “What it is”A Form is a class that declares the inputs a route accepts, one typed FormField per key. When a controller method takes a Form parameter, the generated code reads the body, hands each field its value, parses and validates it, and gives your method the populated form. If anything fails, the client gets one 400 listing every bad field and your method never runs.
When to use it
Section titled “When to use it”- Endpoints that take user input with rules: required fields, lengths, ranges, formats, allowed file types.
- Endpoints that must accept both JSON and HTML form posts with the same code.
- File uploads mixed with text fields (
FileField). - Whenever you want validation errors in one consistent shape (
validation_failedwith per-field messages).
When not to use it
Section titled “When not to use it”- Trusted, structured payloads (internal service calls, webhooks with a fixed schema): a class with a
fromJsonfactory is shorter. Binding errors there still become a400, but without per-field messages. - JSON arrays or deeply nested documents: forms bind a flat object. Use a
List<T>orMap<String, dynamic>body parameter, or aJsonFieldplus your own checks. - Raw bodies (binary uploads, signed webhook payloads you must verify byte for byte): take a
Requestparameter and read it yourself.
How it works
Section titled “How it works”bindFormpicks a decoder fromContent-Type: JSON must be an object; url-encoded and multipart bodies are parsed into text fields and files. NoContent-Typebinds an empty map, so required fields report errors. Other types are a400.- For each declared field, the field gets only
data[field.name]. Keys you didn’t declare are ignored, so clients can’t set fields you didn’t ask for (no mass assignment). - Absent values (
null, or''for text) are an error whenrequired, otherwisenull. parseconverts the raw value to the field’s type (IntFieldaccepts42and"42";BoolFieldacceptstrue,"on","1").- Validators run on the parsed value; the first message wins.
- Errors from all fields are collected and thrown as one
FormValidationException, rendered as:
{"error": {"status": 400, "code": "validation_failed", "message": "Invalid form data", "details": {"email": "This field is required", "password": "Must be at least 8 characters"}}}You then read form.email.value. Required fields are non-null after a successful bind, so value! is safe; optional ones may be null.
Rules that need the database (is this email taken?) belong in the service. Throw FormValidationException(errors: {...}) there to keep the same error shape.
Choosing a body type
Section titled “Choosing a body type”| You need | Use |
|---|---|
| Validation with per-field errors, JSON and HTML forms | Form |
| A typed DTO from trusted JSON | Class with fromJson(Map<String, dynamic>) |
| Different handling for JSON and form posts | Class with both fromJson and fromForm |
| A JSON array | List<T> (with T.fromJson) |
| Whatever was sent | Map<String, dynamic> |
| Several files and fields, no validation | FormData |
| One file | UploadedFile parameter named after the field |
| Raw bytes | Request |
Example
Section titled “Example”class CreateEventForm extends Form { final title = StringField(name: 'title', required: true, minLength: 3, maxLength: 120); final startsAt = DateTimeField( name: 'startsAt', required: true, validators: [(DateTime v) => v.isBefore(DateTime.now()) ? 'Must be in the future' : null], ); final seats = IntField(name: 'seats', min: 1, max: 10000); final isPublic = BoolField(name: 'public');
@override List<FormField<Object?>> collectFields() => [title, startsAt, seats, isPublic];}
@Post('/events')@Permissions({'events:write'})Future<Event> create(CreateEventForm form, EventService events) => events.create( title: form.title.value!, startsAt: form.startsAt.value!, seats: form.seats.value ?? 100, isPublic: form.isPublic.value ?? false, );