Skip to content

Type safety in KosmoJS covers the full request-response cycle: path parameters, payloads, responses, and context/state properties - all driving both compile-time checking and runtime validation from the same type definitions.

πŸ”— Typing Params ​

Parameters are strings by default. Refine them via the second type argument to defineRoute by providing a tuple where each position maps to the corresponding parameter in the path:

api/users/[id]/{action}/index.ts
ts
type UserAction = "retrieve" | "update" | "delete";

export default defineRoute<"users/[id]/{action}", [
  number,      // id
  UserAction,  // action
]>(({ GET }) => [
  GET(async (ctx) => {
    const { id, action } = ctx.validated.params;
    // id: number, action: UserAction | undefined
  }),
]);

Positions are optional - but to refine the second param you must also provide the first.

❗ Inline Tuple Requirement ​

The refinement tuple must be declared inline. Individual type aliases are fine, but a pre-defined tuple type won't work:

ts
// βœ… works
defineRoute<"[id]/[action]", [UserID, UserAction]>

// ❌ won't work - tuple reference loses structural info needed for schema generation
type Params = [UserID, UserAction];
defineRoute<"[id]/[action]", Params>

Refinements also generate runtime validation - invalid params are rejected before your handler runs. (Details ➜ )

πŸ”‹ Typing Payload & Response ​

The first type argument to each method handler defines payload and response schemas:

api/example/index.ts
ts
import type { User } from "~/types";

export default defineRoute<"example">(({ POST }) => [
  POST<{
    json: { name: string; email: string; status?: string },
    response: [200, "json", User],
  }>(async (ctx) => {
    const { name, email, status } = ctx.validated.json;
    const user = await createUser({ name, email, status });

    ctx.body = user;     // Koa
    // ctx.json(user);   // Hono
  }),
]);

Both payload and response are validated at runtime, not just at compile time. (Details ➜ )

πŸ“‹ Typing State & Context ​

defineRoute accepts four type arguments:

ts
defineRoute<
  RouteName,        // required
  ParamsTuple,      // param refinements
  State,            // route-specific state/locals
  Context,          // route-specific context properties
>
ts
defineRoute<
  RouteName,        // required
  ParamsTuple,      // param refinements
  Variables,        // route-specific locals
  Bindings,         // route-specific bindings
>

Use the third and fourth arguments for types that are unique to a specific route:

ts
export default defineRoute<
  "users/[id]",
  [number],
  { permissions: Array<"read" | "write"> },  // ctx.state.permissions
  { authorizedUser: User },                  // ctx.authorizedUser
>(({ GET }) => [
  GET(async (ctx) => {
    const { id } = ctx.validated.params;
    const { permissions } = ctx.state;
    const { authorizedUser } = ctx;
  }),
]);
ts
export default defineRoute<
  "users/[id]",
  [number],
  { permissions: Array<"read" | "write"> },  // ctx.get("permissions")
  { DB: D1Database },                        // Cloudflare binding
>(({ GET }) => [
  GET(async (ctx) => {
    const { id } = ctx.validated.params;
    const permissions = ctx.get("permissions");
    const db = ctx.env.DB;
  }),
]);

If you find yourself declaring the same properties across many routes, move them to the global declarations in api/env.d.ts instead.

βš™οΈ Global Context Types - api/env.d.ts ​

api/env.d.ts extends the default context and state interfaces globally, so every route handler picks them up automatically:

ts
export declare module "_/api" {
  interface DefaultState {
    permissions: Array<"read" | "write" | "admin">;
  }
  interface DefaultContext {
    authorizedUser: User;
  }
}
ts
export declare module "_/api" {
  interface DefaultVariables {
    permissions: Array<"read" | "write" | "admin">;
  }
  interface DefaultBindings {
    DB: D1Database;
  }
}

api/use.ts defines global middleware that runs for every endpoint - the right place to set these properties so they're always available:

api/use.ts
ts
import { use } from "_/api";

export default [
  use(async (ctx, next) => {
    ctx.state.permissions = await getPermissions(ctx);  // Koa
    // ctx.set("permissions", await getPermissions(ctx)); // Hono
    return next();
  }),
];

Important: declaring types in env.d.ts doesn't set the values - you still need the middleware that actually populates them.

Released under the MIT License.