Form Validation
Server-side validation rules for form field values.
Overview
Field definitions in form.fields (snapshotted from templates) support validation constraints beyond required. These are enforced server-side on form save.
Validation happens in two places:
- On template publish — Field definitions validated for consistency
- On form save — Values validated against field definitions
Field Validation Properties
Extended field properties (beyond basic type, label, required):
| Property | Type | Applies to | Description |
|---|---|---|---|
min_length | int | text, textarea | Minimum character count |
max_length | int | text, textarea | Maximum character count |
pattern | string | text, email, phone | Regex pattern for validation |
min | number | number | Minimum numeric value |
max | number | number | Maximum numeric value |
max_file_size | int | file | Maximum file size in bytes |
allowed_file_types | string[] | file | MIME types, e.g. ["image/png", "image/jpeg", "application/pdf"] |
Example Field with Validation
{
"custom_field_id": 10,
"label": "Phone Number",
"type": "phone",
"required": true,
"pattern": "^\\+?[0-9]{7,15}$",
"min_length": 7,
"max_length": 15
}Validation rules:
- Must be provided (required)
- Must be 7-15 characters long
- Must match phone number pattern (optional + prefix, 7-15 digits)
- Value syncs to patient profile (custom_field_values for custom_field_id: 10)
Server-Side Validation Logic
Go Implementation
func (s *formService) validateField(field FieldDef, value any) error {
if value == nil || value == "" {
if field.Required {
return fmt.Errorf("field %q is required", field.Key)
}
return nil // Empty non-required field is valid
}
switch field.Type {
case "text", "textarea", "email", "phone":
str, ok := value.(string)
if !ok {
return fmt.Errorf("field %q: expected string", field.Key)
}
if field.MinLength != nil && len(str) < *field.MinLength {
return fmt.Errorf("field %q: minimum length is %d", field.Key, *field.MinLength)
}
if field.MaxLength != nil && len(str) > *field.MaxLength {
return fmt.Errorf("field %q: maximum length is %d", field.Key, *field.MaxLength)
}
if field.Pattern != nil {
re, err := regexp.Compile(*field.Pattern)
if err != nil {
return fmt.Errorf("field %q: invalid pattern", field.Key)
}
if !re.MatchString(str) {
return fmt.Errorf("field %q: does not match required format", field.Key)
}
}
case "number":
num, ok := toFloat64(value)
if !ok {
return fmt.Errorf("field %q: expected number", field.Key)
}
if field.Min != nil && num < *field.Min {
return fmt.Errorf("field %q: minimum value is %v", field.Key, *field.Min)
}
if field.Max != nil && num > *field.Max {
return fmt.Errorf("field %q: maximum value is %v", field.Key, *field.Max)
}
case "select", "radio":
str, ok := value.(string)
if !ok {
return fmt.Errorf("field %q: expected string", field.Key)
}
if !slices.Contains(field.Options, str) {
return fmt.Errorf("field %q: value %q not in allowed options", field.Key, str)
}
case "checkbox":
// Checkbox value can be bool or []string (multi-select)
switch v := value.(type) {
case bool:
// Single checkbox, always valid
case []any:
for _, item := range v {
str, ok := item.(string)
if !ok || !slices.Contains(field.Options, str) {
return fmt.Errorf("field %q: invalid option %v", field.Key, item)
}
}
default:
return fmt.Errorf("field %q: expected boolean or array", field.Key)
}
case "date":
str, ok := value.(string)
if !ok {
return fmt.Errorf("field %q: expected date string", field.Key)
}
if _, err := time.Parse("2006-01-02", str); err != nil {
return fmt.Errorf("field %q: invalid date format (expected YYYY-MM-DD)", field.Key)
}
case "file":
// File validation happens at upload time, not at form save
// See file upload section
}
return nil
}Validation by Field Type
Text / Textarea / Email / Phone
Validators:
required— Must be non-emptymin_length— Minimum character countmax_length— Maximum character countpattern— Regex pattern (e.g., email format, phone format)
Example:
{
"key": "email",
"label": "Email Address",
"type": "email",
"required": true,
"pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
"max_length": 255
}Error response:
{
"error": {
"code": "validation_error",
"message": "Form validation failed",
"details": {
"errors": [
{"field": "email", "message": "does not match required format"}
]
}
}
}Number
Validators:
required— Must be non-emptymin— Minimum numeric valuemax— Maximum numeric value
Example:
{
"key": "age",
"label": "Age",
"type": "number",
"required": true,
"min": 0,
"max": 150
}Error response:
{
"error": {
"code": "validation_error",
"message": "Form validation failed",
"details": {
"errors": [
{"field": "age", "message": "minimum value is 0"}
]
}
}
}Select / Radio
Validators:
required— Must be non-emptyoptions— Value must be in the allowed options list
Example:
{
"key": "pain_level",
"label": "Pain Level",
"type": "select",
"required": true,
"options": ["No pain", "Less pain", "Big pain"]
}Error response:
{
"error": {
"code": "validation_error",
"message": "Form validation failed",
"details": {
"errors": [
{"field": "pain_level", "message": "value \"Invalid\" not in allowed options"}
]
}
}
}Checkbox
Validators:
required— Must be checked (for single checkbox) or have at least one selection (for multi-select)options— For multi-select, all selected values must be in the allowed options list
Single checkbox:
{
"key": "consent",
"label": "I agree to the terms",
"type": "checkbox",
"required": true
}Value: true or false
Multi-select checkbox:
{
"key": "symptoms",
"label": "Symptoms",
"type": "checkbox",
"required": true,
"options": ["Headache", "Fever", "Cough", "Fatigue"]
}Value: ["Headache", "Fever"]
Date
Validators:
required— Must be non-empty- Format — Must be valid ISO 8601 date (
YYYY-MM-DD)
Example:
{
"key": "birth_date",
"label": "Date of Birth",
"type": "date",
"required": true
}Valid values: "1990-05-15", "2025-01-20"Invalid values: "15/05/1990", "2025-01-20T10:00:00Z"
Error response:
{
"error": {
"code": "validation_error",
"message": "Form validation failed",
"details": {
"errors": [
{"field": "birth_date", "message": "invalid date format (expected YYYY-MM-DD)"}
]
}
}
}File
File validation happens at upload time (not at form save).
See file-uploads.md for details.
Validators:
required— Must have a file uploadedmax_file_size— Maximum file size in bytes (default: 10MB)allowed_file_types— MIME types allowed (default:["image/png", "image/jpeg", "image/webp", "application/pdf"])
Validation Error Response Format
All validation errors return 400 Bad Request with this format:
{
"error": {
"code": "validation_error",
"message": "Form validation failed",
"details": {
"errors": [
{
"field": "phone_number",
"message": "does not match required format"
},
{
"field": "pain_level",
"message": "value \"Invalid\" not in allowed options"
}
]
}
}
}Fields:
code— Always"validation_error"message— Human-readable summarydetails.errors— Array of field-level errorsfield— Field keymessage— Error message
Required Field Validation
All fields marked required: true must have a value before the form can transition to completed status.
On save:
- If any required field is missing → status remains
in_progress - If all required fields filled → status transitions to
completed
On sign:
- If form.status is not
completed→ returns400 form_not_completed
Custom Field Validation
When a form field has custom_field_id set, the Go API validates that the custom field exists and matches the entity type.
On template publish:
func (s *formTemplateService) validateFields(ctx context.Context, fields []FieldDef) error {
for _, field := range fields {
if field.CustomFieldID == nil {
continue
}
customField, err := s.customFieldRepo.Get(ctx, *field.CustomFieldID)
if err != nil {
return fmt.Errorf("custom_field_id %d does not exist", *field.CustomFieldID)
}
if customField.EntityType != "patient" {
return fmt.Errorf("custom_field_id %d is not a patient field", *field.CustomFieldID)
}
}
return nil
}Error response:
{
"error": {
"code": "invalid_custom_field",
"message": "custom_field_id 999 does not exist",
"details": {
"custom_field_id": 999
}
}
}Immutability Validation
Signed forms are immutable. Any attempt to update a signed form returns 409 Conflict:
func (s *formService) SaveForm(ctx context.Context, formID int64, values map[string]any) error {
form, err := s.repo.Get(ctx, formID)
if err != nil {
return err
}
if form.Status == "signed" {
return &ConflictError{
Code: "form_already_signed",
Message: "Cannot update a signed form",
Details: map[string]any{
"form_id": formID,
"signed_at": form.SignedAt,
},
}
}
// Proceed with validation and save
// ...
}Error response:
{
"error": {
"code": "form_already_signed",
"message": "Cannot update a signed form",
"details": {
"form_id": 42,
"signed_at": "2025-01-15T12:00:00Z"
}
}
}Default Validation Rules
When validation constraints are not specified:
| Field Type | Default Constraints |
|---|---|
text | No min/max length, no pattern |
textarea | No min/max length |
email | Pattern: basic email regex |
phone | Pattern: ^\\+?[0-9]{7,15}$ |
number | No min/max |
select | Must be in options list |
radio | Must be in options list |
checkbox | No validation (any boolean or array of options) |
date | Must be valid YYYY-MM-DD |
file | max_file_size: 10MB, allowed_file_types: images + PDF |
Frontend vs Backend Validation
Frontend validation:
- Provides immediate feedback to users
- Improves UX (no round-trip to server)
- Can be bypassed (never trust client-side validation alone)
Backend validation (REQUIRED):
- Always runs server-side (security requirement)
- Cannot be bypassed by manipulating frontend
- Final source of truth for data integrity
Best practice:
- Duplicate validation rules in both frontend and backend
- Frontend validates on blur/change for UX
- Backend validates on save for security