Skip to content

Forms and validation

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.

  • 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_failed with per-field messages).
  • Trusted, structured payloads (internal service calls, webhooks with a fixed schema): a class with a fromJson factory is shorter. Binding errors there still become a 400, but without per-field messages.
  • JSON arrays or deeply nested documents: forms bind a flat object. Use a List<T> or Map<String, dynamic> body parameter, or a JsonField plus your own checks.
  • Raw bodies (binary uploads, signed webhook payloads you must verify byte for byte): take a Request parameter and read it yourself.
  1. bindForm picks a decoder from Content-Type: JSON must be an object; url-encoded and multipart bodies are parsed into text fields and files. No Content-Type binds an empty map, so required fields report errors. Other types are a 400.
  2. 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).
  3. Absent values (null, or '' for text) are an error when required, otherwise null.
  4. parse converts the raw value to the field’s type (IntField accepts 42 and "42"; BoolField accepts true, "on", "1").
  5. Validators run on the parsed value; the first message wins.
  6. 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.

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
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,
);