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
- Catch errors at compile time: Mismatched request/response types fail during development, not in production
- Better IDE support: Autocomplete knows exactly what’s available in
ctx.req and ctx.get()
- Self-documenting code: Types serve as inline documentation for your API
- Refactoring confidence: Change a schema and TypeScript shows you every affected route
- 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.