TriFrost

TriFrost Docs

Learn and build with confidence, from a first project with workers to global scale on bare metal.

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 and Context 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 one
  • ctx.state is fully typed and grows and composes across the middleware chain
  • ctx.setState() builds up state safely and immutably
  • ctx.delState() lets you prune state with type safety
  • ctx.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

Loved the read? Share it with others