In Express.js, every handler receives (req, res, next). In Django, every view receives request. In FastAPI, you declare parameters with type annotations and the framework injects them. In Go, you pass http.ResponseWriter and *http.Request to every handler function. Every framework requires you to declare, receive, and destructure the request context.
FLIN takes a different approach. The request context is injected implicitly. Inside any route handler or middleware, the variables params, query, body, headers, cookies, session, and request are simply available. No function parameters. No imports. No type declarations. They exist because you are inside an HTTP context, and FLIN knows that.
The Built-in Context Variables
Every route handler and middleware in FLIN has access to these variables:
flin// Available in every route handler and middleware
params // URL parameters from dynamic segments
query // Query string parameters
body // Parsed request body (JSON, form, multipart)
headers // Request headers (case-insensitive)
cookies // Request cookies
session // Session data (read/write)
request // Full request object (method, path, ip, etc.)These are not global variables. They are scoped to the current request. Two concurrent requests each see their own params, their own body, their own session. The isolation is guaranteed by the runtime.
How It Works: The Request Scope
When the FLIN runtime dispatches a request to a handler, it creates a new execution scope with the context variables pre-populated:
rustfn execute_handler(
vm: &mut VirtualMachine,
handler: &CompiledFunction,
ctx: &RequestContext,
) -> Result<Value, RuntimeError> {
// Create new scope for this request
let scope = vm.push_scope();
// Inject context variables
scope.set("params", ctx.params.to_flin_value());
scope.set("query", ctx.query.to_flin_value());
scope.set("body", ctx.body.to_flin_value());
scope.set("headers", ctx.headers.to_flin_value());
scope.set("cookies", ctx.cookies.to_flin_value());
scope.set("session", ctx.session.to_flin_value());
scope.set("request", ctx.to_flin_value());
// Execute handler bytecode
let result = vm.execute(handler)?;
// Pop scope (context variables are freed)
vm.pop_scope();
Ok(result)
}The scope is pushed before execution and popped after. Each request gets a clean set of context variables. There is no possibility of leaking data between requests because the scope lifetime is tied to the request lifetime.
Using params
URL parameters from dynamic route segments are available through params:
flin// app/api/users/[id].flin
// Request: GET /api/users/42
route GET {
user_id = params.id // "42" (always a string)
user = User.find(to_int(user_id))
user
}flin// app/products/[category]/[id].flin
// Request: GET /products/electronics/789
route GET {
category = params.category // "electronics"
product_id = params.id // "789"
product = Product.where(category == category).find(to_int(product_id))
product
}Parameters are always strings because they come from URL segments. Use to_int(), to_float(), or other conversion functions when you need typed values. The validate block handles this conversion automatically.
Using query
Query string parameters are available through query:
flin// Request: GET /api/products?page=2&per_page=20&category=shoes&sort=price
route GET {
page = to_int(query.page || "1")
per_page = to_int(query.per_page || "20")
category = query.category // "shoes" or "" if missing
sort_field = query.sort || "created_at"
products = Product
if category != "" {
products = products.where(category == category)
}
products.order(sort_field).limit(per_page).offset((page - 1) * per_page)
}Missing query parameters return an empty string, not null or undefined. This eliminates the null-checking boilerplate that every other framework requires:
javascript// Express.js: defensive coding required
const page = parseInt(req.query.page) || 1;
const category = req.query.category || '';
if (category && typeof category === 'string') { ... }flin// FLIN: query parameters are always strings, never null
page = to_int(query.page || "1")
category = query.category
if category != "" { ... }Using body
The parsed request body is available through body, as covered in detail in the previous article. The key point for context injection is that body adapts to the content type automatically:
flin// JSON request: body is a map with typed values
route POST {
name = body.name // text
age = body.age // int
tags = body.tags // [text]
}
// Form request: body is a map with string values
route POST {
name = body.name // text (always)
age = to_int(body.age) // convert from text
}
// Multipart request: body has both fields and files
route POST {
title = body.title // text field
file = body.avatar // file object
}Using headers
Request headers are available through headers with case-insensitive key access:
flin// Request headers:
// Authorization: Bearer eyJ...
// Accept-Language: fr-FR
// X-Custom-Header: some-value
route GET {
auth = headers["Authorization"] // "Bearer eyJ..."
lang = headers["Accept-Language"] // "fr-FR"
custom = headers["x-custom-header"] // "some-value" (case-insensitive)
}Case-insensitive access is implemented by normalizing header names to lowercase during parsing. This prevents the common bug where code checks for Authorization but the client sends authorization.
Using cookies
Request cookies are available through cookies:
flinroute GET {
theme = cookies["theme"] || "light"
locale = cookies["locale"] || "en"
tracking_id = cookies["_tid"]
}Setting response cookies is done through the response object or through helper functions:
flinroute POST {
set_cookie("theme", body.theme, {
max_age: 365 * 24 * 60 * 60, // 1 year
path: "/",
secure: true,
httponly: false // Accessible to JS for theme
})
{ success: true }
}Using session
The session is a persistent key-value store scoped to the user's browser. It is backed by an encrypted cookie and survives across requests:
flin// Reading session data
user_email = session.user // "" if not set
user_name = session.userName // "" if not set
user_id = session.userId // "" if not set
// Writing session data
session.user = found.email
session.userName = found.name
session.userId = to_text(found.id)
// Clearing session data
session.user = none
session.userName = none
session.userId = noneSession values are always strings. This is a deliberate design constraint. Storing complex objects in sessions leads to serialization bugs, versioning problems, and security vulnerabilities. Strings are simple, serializable, and predictable.
Using request
The request object provides metadata about the raw HTTP request:
flinroute GET {
method = request.method // "GET"
path = request.path // "/api/users/42"
ip = request.ip // "192.168.1.100"
protocol = request.protocol // "https"
host = request.host // "example.com"
user_agent = request.user_agent // "Mozilla/5.0 ..."
}The request object is read-only in route handlers but writable in middleware. This allows middleware to attach computed values that downstream handlers can access:
flin// _middleware.flin
middleware {
token = headers["Authorization"]
if token != "" {
claims = verify_token(token)
request.user = claims
}
next()
}
// api/profile.flin
route GET {
// request.user was set by middleware
if request.user == none {
return error(401, "Not authenticated")
}
User.find(request.user.sub)
}Why Implicit Injection
The decision to inject context implicitly rather than requiring explicit parameter declarations was controversial. Explicit is usually better than implicit -- it is a core principle of good API design. So why did we choose implicit injection?
FLIN is a domain-specific language for web applications. Every FLIN route handler runs in an HTTP context. There is no scenario where a route handler does not have access to params or body. Making developers declare these explicitly would add ceremony without adding information.
Consistency with the rest of FLIN. FLIN entities are available by name without imports. Built-in functions like save, delete, and hash_password are available without imports. Making HTTP context variables require special declaration would be inconsistent.
Reduced boilerplate for beginners. FLIN is designed to be accessible to developers building their first web application. Forcing them to understand dependency injection, parameter binding, or type-annotated extractors before they can read a query parameter would raise the barrier to entry.
The trade-off is discoverability. A new developer reading a FLIN file might wonder where params comes from. The answer is documented, consistent, and always the same: these variables are available in every HTTP context. Once you learn them once, you know them forever.
In the next article, we explore FLIN's middleware system -- how _middleware.flin files create a composable pipeline of request processing that replaces the ad-hoc middleware patterns of Express, Koa, and their descendants.
This is Part 100 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: - [98] API Routes: Backend and Frontend in One File - [99] Auto JSON and Form Body Parsing - [100] Request Context Injection (you are here) - [101] The Middleware System