Back to flin
flin

#113 -- Request Body Validators

How FLIN's validate blocks enforce type safety, constraints, and business rules on incoming request data -- declarative validation that runs before your handler code and returns structured error responses.

Juste A. Gnimavo (Thales) & Claude | March 26, 2026 8 min flin
EN/ FR/ ES
flinvalidatorsrequestvalidation

Input validation is the first line of defense in any web application. Invalid data causes crashes, corrupts databases, enables injection attacks, and creates subtle bugs that manifest in production weeks after deployment. Yet in most frameworks, validation is afterthought code scattered through handlers, duplicated across endpoints, and inconsistently enforced.

FLIN's validate blocks are declarative, composable, and enforced before your handler code runs. You declare what the request body must look like, and FLIN rejects malformed requests automatically with structured error responses. No validation library. No manual checking. No way to forget.

The validate Block

A validate block declares the expected shape and constraints of the request body:

flinroute POST {
    validate {
        name: text @required @minLength(2) @maxLength(100)
        email: text @required @email
        age: int @min(13) @max(120)
        role: text @one_of("user", "admin", "moderator")
        bio: text @maxLength(500)
    }

    // If we reach here, all validations passed
    // body.name is guaranteed to be a string of 2-100 characters
    // body.email is guaranteed to be a valid email
    // body.age is guaranteed to be an integer between 13 and 120

    user = User {
        name: body.name,
        email: body.email,
        age: body.age,
        role: body.role || "user",
        bio: body.bio || ""
    }
    save user

    response { status: 201, body: user }
}

If any field fails validation, FLIN returns a 400 Bad Request with detailed error information:

json{
    "error": "Validation failed",
    "status": 400,
    "fields": {
        "name": "Must be at least 2 characters",
        "email": "Must be a valid email address",
        "age": "Must be at least 13"
    }
}

The handler code never executes. Invalid data never reaches the database. The error response tells the client exactly which fields failed and why.

Available Decorators

FLIN provides a comprehensive set of validation decorators:

Type Decorators

TypeDescriptionCoercion
textString valueFrom any type via to_text()
intInteger valueFrom string via to_int()
floatFloating-point valueFrom string via to_float()
boolBoolean valueFrom "true"/"false"/"1"/"0"
fileUploaded fileFrom multipart part
[type]Array of valuesParsed as JSON array

Constraint Decorators

flinvalidate {
    // Required fields
    name: text @required                  // Must be present and non-empty

    // String constraints
    slug: text @minLength(3) @maxLength(50) @pattern("^[a-z0-9-]+$")
    email: text @email                    // Must match email format
    url: text @url                        // Must be valid URL
    phone: text @phone                    // Must be valid phone number

    // Numeric constraints
    age: int @min(0) @max(150)
    price: float @min(0.01) @max(999999.99)
    quantity: int @required @min(1) @max(10000)

    // Enum constraints
    status: text @one_of("active", "inactive", "pending")
    priority: int @one_of(1, 2, 3, 4, 5)

    // File constraints
    avatar: file @max_size("5MB") @allow_types("image/png", "image/jpeg")
    documents: [file] @max_count(10) @max_size("25MB")

    // Custom validation message
    password: text @required @minLength(8) @message("Password must be at least 8 characters")
}

The @required Decorator

Fields without @required are optional. If absent from the request, they default to an empty value ("" for text, 0 for int, false for bool, none for file).

flinvalidate {
    name: text @required          // Must be present
    nickname: text                // Optional, defaults to ""
    email: text @required @email  // Must be present AND valid
}

The @pattern Decorator

For validation that does not fit a built-in decorator, use @pattern with a regular expression:

flinvalidate {
    slug: text @required @pattern("^[a-z0-9][a-z0-9-]*[a-z0-9]$")
    postal_code: text @pattern("^[0-9]{5}$")
    hex_color: text @pattern("^#[0-9a-fA-F]{6}$")
}

Type Coercion

The validate block performs type coercion for form-encoded data. Form values are always strings, but the validator converts them to the declared type:

flinvalidate {
    quantity: int @required @min(1)
    // Form sends "5" (string) -> validator converts to 5 (int)

    price: float @required @min(0.01)
    // Form sends "29.99" (string) -> validator converts to 29.99 (float)

    active: bool
    // Form sends "true" (string) -> validator converts to true (bool)
}

JSON requests already have typed values, so coercion is a no-op. This means the same validate block works for both JSON and form-encoded requests.

Nested Object Validation

For complex request bodies with nested objects:

flinvalidate {
    user: {
        name: text @required @minLength(2)
        email: text @required @email
    }
    address: {
        street: text @required
        city: text @required
        postal_code: text @required @pattern("^[0-9]{5}$")
        country: text @required @one_of("CI", "SN", "NG", "GH", "KE")
    }
    items: [{
        product_id: int @required
        quantity: int @required @min(1)
    }]
}

The client sends:

json{
    "user": { "name": "Thales", "email": "[email protected]" },
    "address": { "street": "Rue des Jardins", "city": "Abidjan", "postal_code": "01234", "country": "CI" },
    "items": [
        { "product_id": 1, "quantity": 2 },
        { "product_id": 5, "quantity": 1 }
    ]
}

Each nested field is validated individually. Errors are reported with dot-path notation:

json{
    "error": "Validation failed",
    "fields": {
        "address.postal_code": "Must match pattern ^[0-9]{5}$",
        "items[1].quantity": "Must be at least 1"
    }
}

How Validation Is Implemented

The validate block compiles to a validation function that runs before the handler:

rustpub struct ValidateField {
    name: String,
    field_type: FieldType,
    required: bool,
    constraints: Vec<Constraint>,
    custom_message: Option<String>,
}

pub enum Constraint {
    MinLength(usize),
    MaxLength(usize),
    Min(f64),
    Max(f64),
    Pattern(Regex),
    Email,
    Url,
    Phone,
    OneOf(Vec<Value>),
    MaxSize(usize),
    AllowTypes(Vec<String>),
    MaxCount(usize),
}

fn validate_body(
    body: &Value,
    fields: &[ValidateField],
) -> Result<Value, ValidationErrors> {
    let mut errors = HashMap::new();
    let mut coerced = body.clone();

    for field in fields {
        let value = body.get(&field.name);

        // Check required
        if field.required && (value.is_none() || value == Some(&Value::Empty)) {
            errors.insert(field.name.clone(), "This field is required".into());
            continue;
        }

        if let Some(val) = value {
            // Type coercion
            let typed = coerce(val, &field.field_type)?;

            // Constraint checking
            for constraint in &field.constraints {
                if let Err(msg) = check_constraint(&typed, constraint) {
                    errors.insert(
                        field.name.clone(),
                        field.custom_message.as_deref().unwrap_or(&msg).to_string(),
                    );
                    break;
                }
            }

            coerced.set(&field.name, typed);
        }
    }

    if errors.is_empty() {
        Ok(coerced)
    } else {
        Err(ValidationErrors { fields: errors })
    }
}

The validation function: 1. Iterates over all declared fields. 2. Checks @required first. 3. Performs type coercion. 4. Evaluates each constraint in order (stops at first failure per field). 5. Returns either the coerced body or a map of field errors.

Reusable Validation Schemas

When multiple endpoints share the same validation rules, FLIN allows extracting validators into reusable schemas:

flin// Define once
schema UserInput {
    name: text @required @minLength(2) @maxLength(100)
    email: text @required @email
    role: text @one_of("user", "admin", "moderator")
}

// Use in multiple routes
route POST {
    validate UserInput
    // ...
}

route PUT {
    validate UserInput
    // Same validation, different handler
}

Validation vs Guards

Validation and guards serve different purposes and complement each other:

Guards protect access: who can call this endpoint? Guards run before the request body is even parsed. An unauthenticated user is rejected before their request body is read.

Validators protect data: what shape must the input have? Validators run after guards pass and the body is parsed. They ensure the data is well-formed before the handler processes it.

flinguard auth                    // Who: authenticated users only
guard role("admin")           // Who: admins only
guard rate_limit(10, 60)      // How often: 10/minute

route POST {
    validate {                // What: well-formed user data
        name: text @required
        email: text @required @email
    }

    // If we reach here:
    // 1. User is authenticated (guard auth)
    // 2. User is admin (guard role)
    // 3. Request is within rate limit (guard rate_limit)
    // 4. Body has valid name and email (validate)
}

Four layers of protection, each expressed declaratively, each enforced automatically.

Error Response Consistency

Every validation error response follows the same structure:

json{
    "error": "Validation failed",
    "status": 400,
    "fields": {
        "field_name": "Human-readable error message"
    }
}

This consistency means frontend code can handle validation errors generically:

javascript// Frontend code (any framework)
const response = await fetch('/api/users', { method: 'POST', body: data });
if (response.status === 400) {
    const { fields } = await response.json();
    Object.entries(fields).forEach(([field, message]) => {
        showError(field, message);
    });
}

The field names in the error response match the field names in the request body. No mapping required.

FLIN's validation is not a library you install. It is not middleware you configure. It is a language feature that compiles to efficient type-checking code, produces clear error messages, and cannot be bypassed. Every API endpoint that uses a validate block is protected against malformed input, every time, automatically.

In the next article, we look at how we verified all of these security features work correctly: 75 security tests covering authentication, authorization, rate limiting, input validation, and cryptographic operations.


This is Part 113 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.

Series Navigation: - [112] WhatsApp OTP Authentication for Africa - [113] Request Body Validators (you are here) - [114] 75 Security Tests: How We Verified Everything - [115] Custom Guards and Security Middleware

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles

Thales & Claude thales

Thirteen Agents, Forty-Three Minutes: The First Claude Fable 5 Workflow Session, And What A Deterministic Orchestration Script Changes About Multi-Agent Builds

One prompt, thirteen agents, forty-three minutes: the first production session with Claude Fable 5 and Claude Code's Workflow tool shipped a complete seven-page production website plus a backend lead-capture endpoint in a single commit. The build log: the deterministic orchestration script, the contract-injection pattern between phases, the per-agent economics of the parallel fan-out, and the session-limit cliffhanger the resume journal turned into a non-event.

20 min Jun 12, 2026
claude-fable-5claude-codeworkflow-toolmulti-agent +10
Thales & Claude casp

The gate caught its own drift: one day inside CASP with Claude Fable 5

We handed the most autonomous Claude model yet the keys to CASP — the open-source CLI that keeps AI coding agents honest against git — with the authority to reject our own roadmap. It rejected five things, found two real bugs in the validator by dogfooding it, fixed them under a two-auditor gate, and left casp check fully green on its own repo for the first time. CASP 0.3.0 is the result.

14 min Jun 10, 2026
caspzerosuiteworkflowai-cto +9
Thales & Claude zerosuite

The CASP Transplant: How The Six-File Discipline Moved From Conductor To An Anti-Fraud Transport ERP, What The /next Skill Adds When The Operator Just Types 'next', And Why The Cost Of CASP Drift Rises When The Project Is Someone Else's Cash

The CASP discipline that ran thirty-five Conductor sessions is product-agnostic. The build log of transplanting it to KASSIA, an anti-fraud transport ERP for a Côte d'Ivoire fleet operator: what moved, what did not (the bespoke validator — and what its absence costs), what the /next skill adds when the operator types one word, and where the CASP stops — the deployment bug it could not see because it records intent, not infrastructure reality.

20 min Jun 8, 2026
kassiaerp-kassia-transport-logistiquezerosuiteCASP +15