Skip to main content
Semola’s type safety isn’t an afterthought—it’s the foundation. From request validation to response serialization, TypeScript knows exactly what data flows through your API.

Request to response type inference

When you define a route schema, TypeScript automatically infers the types for your request data:
import { Api } from 'semola/api';
import { z } from 'zod';

const api = new Api();

api.defineRoute({
  path: '/users/:id',
  method: 'GET',
  request: {
    params: z.object({ id: z.string() }),
    query: z.object({ include: z.enum(['posts', 'comments']).optional() }),
  },
  response: {
    200: z.object({ 
      id: z.string(), 
      name: z.string(),
      email: z.string().email() 
    }),
  },
  handler: async (ctx) => {
    // TypeScript knows:
    // ctx.params.id is string
    // ctx.query.include is 'posts' | 'comments' | undefined
    
    const user = await db.getUser(ctx.params.id);
    
    // TypeScript enforces response shape
    return ctx.json(200, {
      id: user.id,
      name: user.name,
      email: user.email,
      // TypeScript error: 'password' doesn't exist in response schema
      // password: user.password,
    });
  },
});

How type inference works

Semola uses TypeScript’s advanced type system to extract type information from Standard Schema validators. The magic happens in the InferOutput and InferInput types:
src/lib/api/core/types.ts
import type { StandardSchemaV1 } from "@standard-schema/spec";

// Safely extracts output types from Standard Schema
type SafeTypeAccess<
  T,
  K extends "input" | "output",
> = T extends StandardSchemaV1
  ? T["~standard"] extends { types?: infer U }
    ? U extends Record<K, infer V>
      ? V
      : never
    : never
  : undefined;

export type InferOutput<T extends StandardSchemaV1 | undefined> =
  SafeTypeAccess<T, "output">;

export type InferInput<T extends StandardSchemaV1 | undefined> = 
  SafeTypeAccess<T, "input">;
This type-level programming ensures that:
  • Request schemas define what comes into your handler
  • Response schemas define what goes out of your handler
  • TypeScript catches mismatches at compile time

Context type inference

The Context type merges request schemas, response schemas, and middleware extensions into a single typed object:
src/lib/api/core/types.ts
export type Context<
  TReq extends RequestSchema = RequestSchema,
  TRes extends ResponseSchema | undefined = undefined,
  TExt extends Record<string, unknown> = Record<string, unknown>,
> = {
  raw: Request;
  req: {
    body: InferOutput<TReq["body"]>;
    query: InferOutput<TReq["query"]>;
    headers: InferOutput<TReq["headers"]>;
    cookies: InferOutput<TReq["cookies"]>;
    params: InferOutput<TReq["params"]>;
  };
  json: <S extends ExtractStatusCodesOrAny<TRes>>(
    status: S,
    data: TRes extends ResponseSchema ? InferOutput<TRes[S]> : unknown,
  ) => Response;
  text: (status: number, text: string) => Response;
  html: (status: number, html: string) => Response;
  redirect: (status: number, url: string) => Response;
  get: <K extends keyof TExt>(key: K) => TExt[K];
};

Breaking it down

  • TReq: Request schema types are inferred and available in ctx.req
  • TRes: Response schema types constrain what you can return from ctx.json()
  • TExt: Middleware extensions are merged and typed in ctx.get()

Status code enforcement

When you define response schemas, TypeScript restricts which status codes you can use:
api.defineRoute({
  path: '/posts',
  method: 'POST',
  response: {
    201: z.object({ id: z.string(), title: z.string() }),
    400: z.object({ message: z.string() }),
  },
  handler: async (ctx) => {
    // ✅ TypeScript allows 201
    return ctx.json(201, { id: '123', title: 'Hello' });
    
    // ✅ TypeScript allows 400
    // return ctx.json(400, { message: 'Invalid input' });
    
    // ❌ TypeScript error: 404 not in response schema
    // return ctx.json(404, { message: 'Not found' });
  },
});
The ExtractStatusCodes type extracts numeric keys from your response schema:
src/lib/api/core/types.ts
export type ExtractStatusCodes<T extends ResponseSchema> = keyof T & number;

export type ExtractStatusCodesOrAny<T extends ResponseSchema | undefined> =
  T extends ResponseSchema ? ExtractStatusCodes<T> : number;

Middleware type safety

Middleware extensions are automatically merged into your route handler’s context type. Here’s how it works:
import { Middleware } from 'semola/api';
import { z } from 'zod';

// Define middleware that adds userId to context
const authMiddleware = new Middleware({
  request: {
    headers: z.object({ authorization: z.string() }),
  },
  handler: async (ctx) => {
    const token = ctx.req.headers.authorization;
    const userId = await verifyToken(token);
    
    // Return object becomes part of route context
    return { userId };
  },
});

const api = new Api({
  middlewares: [authMiddleware],
});

api.defineRoute({
  path: '/me',
  method: 'GET',
  response: {
    200: z.object({ id: z.string(), name: z.string() }),
  },
  handler: async (ctx) => {
    // TypeScript knows userId exists from middleware
    const userId = ctx.get('userId');
    const user = await db.getUser(userId);
    return ctx.json(200, user);
  },
});
The type system automatically merges middleware extensions:
src/lib/api/middleware/types.ts
export type InferMiddlewareExtension<T extends Middleware> =
  T extends Middleware<infer Req, infer Ext>
    ? Ext
    : never;

export type MergeMiddlewareExtensions<T extends readonly Middleware[]> =
  T extends readonly [infer First, ...infer Rest]
    ? First extends Middleware
      ? Rest extends readonly Middleware[]
        ? InferMiddlewareExtension<First> & MergeMiddlewareExtensions<Rest>
        : InferMiddlewareExtension<First>
      : Record<string, never>
    : Record<string, never>;

Type-safe validation

Semola validates requests and responses at runtime using Standard Schema, but TypeScript knows the types before validation runs:
src/lib/api/validation/index.ts
import type { StandardSchemaV1 } from "@standard-schema/spec";
import { err, ok } from "../../errors/index.js";

export const validateSchema = async <T>(
  schema: StandardSchemaV1,
  data: unknown,
) => {
  const result = await schema["~standard"].validate(data);

  if (!result.issues) {
    return ok(result.value as T);
  }

  const issues = result.issues.map((issue) => {
    let path = "unknown";
    if (Array.isArray(issue.path)) {
      path = issue.path.map(String).join(".");
    }
    return `${path}: ${issue.message ?? "validation failed"}`;
  });

  return err("ValidationError", issues.join(", "));
};
Validation returns a result tuple, so TypeScript enforces error checking:
const [error, validated] = await validateSchema(schema, data);

if (error) {
  // TypeScript knows 'validated' is null
  return ctx.json(400, { message: error.message });
}

// TypeScript knows 'error' is null and 'validated' is the correct type
return ctx.json(200, validated);

Real-world example

Here’s a complete example showing how types flow through a route:
import { Api } from 'semola/api';
import { z } from 'zod';

const api = new Api();

api.defineRoute({
  path: '/users/:id',
  method: 'PATCH',
  request: {
    params: z.object({ 
      id: z.string().uuid() 
    }),
    body: z.object({ 
      name: z.string().min(1).optional(),
      email: z.string().email().optional() 
    }),
  },
  response: {
    200: z.object({ 
      id: z.string(), 
      name: z.string(), 
      email: z.string() 
    }),
    404: z.object({ 
      message: z.string() 
    }),
  },
  handler: async (ctx) => {
    // ctx.params.id is string (validated UUID)
    // ctx.body is { name?: string, email?: string }
    
    const user = await db.findUser(ctx.params.id);
    
    if (!user) {
      // TypeScript enforces 404 response shape
      return ctx.json(404, { message: 'User not found' });
    }
    
    const updated = await db.updateUser(ctx.params.id, ctx.req.body);
    
    // TypeScript enforces 200 response shape
    return ctx.json(200, {
      id: updated.id,
      name: updated.name,
      email: updated.email,
    });
  },
});

Benefits of strong typing

  1. Catch errors at compile time: Mismatched request/response types fail during development, not in production
  2. Better IDE support: Autocomplete knows exactly what’s available in ctx.req and ctx.get()
  3. Self-documenting code: Types serve as inline documentation for your API
  4. Refactoring confidence: Change a schema and TypeScript shows you every affected route
  5. No type assertions: No need for as or ! - types are inferred correctly
Semola’s type inference works with any Standard Schema validator. Switch from Zod to Valibot or ArkType without changing your route handlers.

Build docs developers (and LLMs) love