Back to flin
flin

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.

Thales & Claude | March 25, 2026 8 min flin
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