Context & State Management
Every request in TriFrost travels through your app wrapped in a powerful, fully typed Context
object β your window into the request, environment, state, and control flow.
This doc covers:
- An intro to what the
Context
is - How
ctx.state
evolves and is enforced - How typing flows across middleware and handlers
- Why this makes your app safer, leaner, and smarter
π§± What is Context?
The Context
or ctx
is passed into every middleware and handler.
It includes things such as:
- ctx.env: Typed runtime environment (eg: config, secrets, etc)
- ctx.state: Request-scoped, typed key-value store
- ctx.query: Parsed query parameters
- ctx.body: Parsed request body (if applicable)
- ctx.cache: Per-request cache instance
- ctx.cookies: Cookie read/write API
- ctx.logger: Per-request structured logger
- ctx.headers: Parsed request headers
As well as a whole lot more, no boilerplate/custom scaffolding, you get this for every request, in every runtime.
π Read more about Context and all it offers here
π§© State from Route Params
State is automatically initialized with any dynamic path parameters.
For Example:
app
.group('/users/:userId', router => {
router
/* ctx.state is typed as {userId: string} */
.get('', async ctx => {
...
})
/* ctx.state is typed as {userId: string; assetId: string} */
.get('/assets/:assetId', async ctx => {
...
})
})
});
This means your ctx.state
always includes param values, typed as string
, and you can safely build logic or access guards on top of them.
π‘ Type-Safe Context Definitions
You will see this done in many of the TriFrost examples, but we highly encourage you to define your own Context
, Router
and Env
types to ensure ergonomic DX and full type coverage.
Example:
// types.ts
import {
type TriFrostContext,
type TriFrostRouter
} from '@trifrost/core';
export type Env = {
DB_URL: string;
COOKIE_SECRET: string;
};
export type Context<State = {}> = TriFrostContext<Env, State>;
export type Router<State = {}> = TriFrostRouter<Env, State>;
Now use these everywhere β app, routers, middleware, and handlers β and TypeScript will enforce correctness from top to bottom.
π Chaining State Across Middleware
TriFrost's middleware chain is compositional β each middleware can extend `ctx.state
`, and downstream middleware/handlers will inherit it.
app
.use(authenticateUser) // Adds $auth to state
.group('/dashboard', router => {
router
.use(loadSettings) // Adds settings to state
.get(myDash) // state has $auth and settings
})
.get('/', myHome) // state has $auth
Each .setState({...})
call extends the state immutably and inferably, and the full shape is passed downstream.
export async function authenticateUser <S> (ctx:Context<S>) {
const user = await getUser(ctx);
return ctx.setState({ $auth: user });
}
export async function myDash <S extends {$auth: User}> (ctx:Context<S>) {
console.log(ctx.state.$auth.name); // fully typed!
}
Note: TriFrostβs built-in middlewares β like Auth, Rate Limiting, Session β follow this pattern too. For example all TriFrost auth middleware implementation automatically set
$auth
on state.
Call setState() at the end
For the best type inference and DX, always make sure ctx.setState(...)
is your final returned value from a middleware:
export async function loadSettings <S> (ctx:Context<S>) {
const settings = await fetchSettings(ctx);
return ctx.setState({ settings });
}
This lets TypeScript automatically carry forward the new state into the next middleware/handler.
TriFrost wraps the return value of every middleware to propagate types down the chain β but only if you return the updated context
What if you canβt do that?
If your logic requires additional work after calling setState()
, youβll need to manually specify the updated state shape in the return type:
export async function fetchUser <S> (ctx:Context<S>) {
const user = await getUserFromDB(ctx);
ctx.setState({ user });
// more logic...
return ctx as Context<S & {user:User}>;
}
This ensures that the correct shape is preserved for the next step.
Note: This is opinionated, but in most cases you should keep middleware as close to single-responsibility as possible.
π Declaring State Requirements
Handlers (and middleware) can declare what they expect on ctx.state
. This acts like a guard and a compiler-enforced contract.
export async function managementDash <S extends {$auth:User}> (ctx:Context<S>) {
/* In theory, this isnβt necessary β but Iβm a defensive coder. */
if (!ctx.state.$auth.isAdmin) return ctx.abort(403);
}
The managementDash
in our example has declared that it expects a $auth to be on the incoming state, if upstream middleware didn't set $auth
, TypeScript will immediately warn you.
app
/* Typescript will complain as $auth will NOT be available at this point */
.get('/management', managementDash);
This ensures you have fail-fast typing across your app.
ποΈ Cleaning Up State
Need to drop something from state?
ctx.delState("userPrefs");
The key is removed from both the runtime ctx.state
object and its type signature, reducing bleed and stale usage.
π Runtime-Aware ctx.env
Your environment (ctx.env
) contains runtime-specific config and bindings. You define its shape with your App
instance:
const app = new App<Env>();
Then access it from any context:
ctx.env.DB_URL;
ctx.env.COOKIE_SECRET;
Each runtime adapter (Node, Bun, uWS, Cloudflare Workers) populates this object automatically.
Note: We suggest, much like your own
Router
andContext
types that you create an Env type, see Type-Safe Context Definitions. π This is something we can't automatically infer, but at least we can make its shape easily accessible π€.
TLDR
Context
= lifecycle control, state, env, and utilities in onectx.state
is fully typed and grows and composes across the middleware chainctx.setState()
builds up state safely and immutablyctx.delState()
lets you prune state with type safetyctx.env
gives you runtime-bound configuration- Param values (eg:
/user/:userId
) are automatically part of state as strings - Handlers can tell upstream they require specific state shape via
S extends {...}
in any context - Define
Context
,Router
,Env
types once to lock in safety everywhere
Next Steps
- Learn how to Compose Middleware Safely
- Explore the Request Lifecycle
- Dive into Fallback and Error Handling