Skip to content

abersheeran/typedapi.ts

Repository files navigation

typedapi.ts

A type-safe Web framework based on the standard fetch interface, using Typia for runtime validation.

AI Skills

Install the Agent Skill for AI-assisted development with typedapi.ts:

npx skills add abersheeran/typedapi.ts

This gives Claude Code, Cursor, GitHub Copilot, and other AI agents context about the framework's API, patterns, and conventions.

Installation

npm install typedapi.ts

typia and ts-patch are required peer dependencies and are installed automatically alongside typedapi.ts (npm 7+). Use tspc (provided by ts-patch) instead of tsc in your build scripts to apply the typedapi.ts transformer. tspc is a drop-in tsc replacement with no global side effects.

TypeScript Transform Setup

Use tspc in your package.json build scripts:

{
  "scripts": {
    "build": "tspc -p tsconfig.json"
  }
}

tspc is shipped by ts-patch as a drop-in replacement for tsc. It applies custom transformers at compile time without patching your TypeScript installation.

Add the plugin in tsconfig.json:

{
  "compilerOptions": {
    "plugins": [
      { "transform": "typedapi.ts/transform" }
    ]
  }
}

Then run npm run build or npx tspc directly instead of tsc.

With this configuration, the framework automatically generates OpenAPI parameter and response schemas from the handler's parameter types and return types at compile time, with no manual declarations required.

Runtime Validation

Add the typia plugin in tsconfig.json (it must come after the typedapi.ts transform):

{
  "compilerOptions": {
    "plugins": [
      { "transform": "typedapi.ts/transform" },
      { "transform": "typia/lib/transform" }
    ]
  }
}

Any file that passes validate to api() must import typia from "typia" and create the validator manually. The transformer does not auto-generate validate.

When handler params include Inject<typeof dependency> fields, wrap the handler param type with RequestParams<T> so Typia only validates request-sourced fields.

Start the Server

createRouter<T>() returns a (request: Request, context?: T) => Promise<Response> handler. The optional context is passed to handlers, middleware, and inject functions as part of the second argument { request, context }:

It also answers OPTIONS requests automatically: explicit OPTIONS routes are dispatched first, and only unmatched OPTIONS requests fall back to 204 No Content with an Allow header listing the matched methods plus OPTIONS. Pass { middlewares, onError } as the second argument when you need router-level middleware or app-wide error handling that wraps the entire dispatch flow.

import { api, createRouter } from "typedapi.ts";

const health = api({ method: "GET", path: "/health" }, async () => {
  return { status: "ok" };
});

const handler = createRouter<Env>([health]);

// Cloudflare Workers
export default {
  fetch(request: Request, env: Env) {
    return handler(request, env);
  },
};

Usage

Basic CRUD

import { api, createRouter, Json, JsonResponse, Path } from "typedapi.ts";

interface Order {
  id: number;
  customer: string;
  status: "draft" | "paid" | "shipped";
}

const orders = new Map<number, Order>([
  [1, { id: 1, customer: "Acme Corp", status: "draft" }],
]);

const createOrder = api(
  { method: "POST", path: "/orders" },
  async (params: {
    customer: Json<string>;
    status: Json<Order["status"]>;
  }): Promise<JsonResponse<200, {}, Order>> => {
    const id = orders.size + 1;
    const order = { id, customer: params.customer, status: params.status };
    orders.set(id, order);
    return order;
  },
);

const getOrder = api(
  { method: "GET", path: "/orders/:id" },
  async (params: { id: Path<number> }) => {
    return orders.get(params.id) ?? { message: "Order not found" };
  },
);

const updateOrder = api(
  { method: "PUT", path: "/orders/:id" },
  async (params: {
    id: Path<number>;
    status: Json<Order["status"]>;
  }) => {
    const current = orders.get(params.id);
    if (!current) {
      return { message: "Order not found" };
    }
    const next = { ...current, status: params.status };
    orders.set(params.id, next);
    return next;
  },
);

const deleteOrder = api(
  { method: "DELETE", path: "/orders/:id" },
  async (params: { id: Path<number> }) => {
    const deleted = orders.delete(params.id);
    return { deleted, id: params.id };
  },
);

export default createRouter([
  createOrder,
  getOrder,
  updateOrder,
  deleteOrder,
]);

Path Parameters

import { api, createRouter, Path } from "typedapi.ts";

const getInvoice = api(
  { method: "GET", path: "/accounts/:accountId/invoices/:invoiceId" },
  async (params: {
    accountId: Path<number>;
    invoiceId: Path<string>;
  }) => {
    return {
      accountId: params.accountId,
      invoiceId: params.invoiceId,
      issuedAt: "2026-03-01",
    };
  },
);

export default createRouter([getInvoice]);

Query Parameters

import { api, createRouter, Query } from "typedapi.ts";

const searchCatalog = api(
  { method: "GET", path: "/catalog/search" },
  async (params: {
    q: Query<string>;
    page: Query<number>;
    tags: Query<string[]>;
  }) => {
    return {
      keyword: params.q,
      page: params.page,
      tags: params.tags,
      total: 42,
    };
  },
);

export default createRouter([searchCatalog]);

Header Parameters

import { api, createRouter, Header } from "typedapi.ts";

const getProfile = api(
  { method: "GET", path: "/me" },
  async (params: {
    authorization: Header<string>;
    "x-trace-id": Header<string>;
  }) => {
    return {
      token: params.authorization.replace("Bearer ", ""),
      traceId: params["x-trace-id"],
    };
  },
);

export default createRouter([getProfile]);

Cookie Parameters

import { api, createRouter, Cookie } from "typedapi.ts";

const getCart = api(
  { method: "GET", path: "/cart" },
  async (params: {
    session: Cookie<string>;
    locale: Cookie<string>;
  }) => {
    return {
      session: params.session,
      locale: params.locale ?? "en-US",
      items: 3,
    };
  },
);

export default createRouter([getCart]);

JSON Request Body

import { api, createRouter, Json, JsonResponse } from "typedapi.ts";

interface Ticket {
  id: number;
  title: string;
  priority: "low" | "medium" | "high";
}

const createTicket = api(
  { method: "POST", path: "/tickets" },
  async (params: {
    title: Json<string>;
    priority: Json<Ticket["priority"]>;
  }): Promise<JsonResponse<200, {}, Ticket>> => {
    return {
      id: 101,
      title: params.title,
      priority: params.priority,
    };
  },
);

export default createRouter([createTicket]);

Form Request Body

import { api, createRouter, type Form } from "typedapi.ts";

const submitForm = api(
  { method: "POST", path: "/contact" },
  async (params: {
    name: Form<string>;
    email: Form<string>;
    message: Form<string>;
  }) => {
    return { received: true, name: params.name };
  },
);

export default createRouter([submitForm]);

Supports application/x-www-form-urlencoded and multipart/form-data. In multipart requests, file fields are passed in as File objects.

Request Context

Access the raw Request and custom context via the handler's second argument:

import { api, createRouter } from "typedapi.ts";

const info = api(
  { method: "GET", path: "/info" },
  async (_params, { request }) => {
    return { url: request.url, method: request.method };
  },
);

export default createRouter([info]);

Custom Context

Pass custom context (e.g. Cloudflare Workers env) as the second argument to the router handler. Specify the type via the generic on createRouter<T>():

import { api, createRouter, type HandlerContext } from "typedapi.ts";

interface Env {
  DB: D1Database;
}

const getUser = api(
  { method: "GET", path: "/user/:id" },
  async (params, { context }: HandlerContext<Env>) => {
    const user = await context.DB.prepare("SELECT * FROM users WHERE id = ?").first();
    return user;
  },
);

const handler = createRouter<Env>([getUser]);

// Cloudflare Workers
export default {
  fetch(request: Request, env: Env) {
    return handler(request, env);
  },
};

Using context in inject

import { inject, type HandlerContext } from "typedapi.ts";

interface Env {
  DB: D1Database;
}

const db = inject(async (_params, { context }: HandlerContext<Env>) => {
  return context.DB;
});

Automatic Response Conversion

api() automatically converts the handler's return value into a Response:

Return Value Response
Response Passed through unchanged
null 204 No Content
string text/plain; charset=utf-8
URL 307 Redirect
ReadableStream application/octet-stream
AsyncIterable text/event-stream
Any other value JSON response
import { api, createRouter, text } from "typedapi.ts";

const items = new Map<number, { id: number }>();

// Response → passed through unchanged
const health = api(
  { method: "GET", path: "/health" },
  async () => text("ok", 200, { "x-service": "typedapi-ts" }),
);

// string → text/plain
const greet = api(
  { method: "GET", path: "/greet" },
  async () => "hello world",
);

// null → 204 No Content
const deleteItem = api(
  { method: "DELETE", path: "/items/:id" },
  async (params: { id: number }) => {
    items.delete(params.id);
    return null;
  },
);

// ReadableStream → application/octet-stream
const download = api(
  { method: "GET", path: "/download" },
  async () =>
    new ReadableStream({
      start(controller) {
        controller.enqueue(new TextEncoder().encode("hello\n"));
        controller.close();
      },
    }),
);

// AsyncIterable → SSE (text/event-stream)
const events = api(
  { method: "GET", path: "/events" },
  async function* () {
    yield { type: "ping" };
    yield { type: "data", payload: 42 };
  },
);

// object → JSON (default)
const getUser = api(
  { method: "GET", path: "/users/:id" },
  async (params: { id: number }) => ({
    id: params.id,
    name: "Alice",
  }),
);

export default createRouter([health, greet, deleteItem, download, events, getUser]);
import { api, createRouter } from "typedapi.ts";

const goToDocs = api(
  { method: "GET", path: "/docs" },
  async () => new URL("https://example.com/docs"),
);

export default createRouter([goToDocs]);

JSON Responses

import { api, createRouter, json, type JsonResponse } from "typedapi.ts";

type CreateUserResult =
  | JsonResponse<201, { location: string }, { id: number; name: string }>
  | JsonResponse<409, {}, { message: string }>;

const createUser = api(
  { method: "POST", path: "/users" },
  async (): Promise<CreateUserResult> =>
    json({ id: 1, name: "Alice" }, 201, { location: "/users/1" }),
);

export default createRouter([createUser]);

HTML Responses

import { api, createRouter, html } from "typedapi.ts";

const renderDashboard = api(
  { method: "GET", path: "/dashboard" },
  async () =>
    html(`<!doctype html>
    <html lang="en">
      <body>
        <h1>Revenue Dashboard</h1>
        <p>Updated at 2026-03-19T09:00:00Z</p>
      </body>
    </html>`),
);

export default createRouter([renderDashboard]);

Plain Text Responses

import { api, createRouter, text } from "typedapi.ts";

const exportRobots = api(
  { method: "GET", path: "/robots.txt" },
  async () =>
    text("User-agent: *\nAllow: /\nSitemap: https://example.com/sitemap.xml"),
);

export default createRouter([exportRobots]);

Set-Cookie Serialization

The headers parameter of json() / html() / text() / stream() / sse() / file() supports both string and string[]. When an array is passed, headers with the same name are appended, which is useful for multiple Set-Cookie values; explicitly passing content-type overrides the default.

import { api, cookie, clearCookie, json } from "typedapi.ts";

const signIn = api(
  { method: "POST", path: "/sessions" },
  async () =>
    json(
      { ok: true },
      200,
      {
        "set-cookie": [
          cookie("session", "token-123", {
            path: "/",
            httpOnly: true,
            sameSite: "Lax",
          }),
          cookie("refresh", "token-456", {
            path: "/",
            httpOnly: true,
            sameSite: "Lax",
          }),
        ],
      },
    ),
);

const signOut = api(
  { method: "DELETE", path: "/sessions" },
  async () =>
    json(
      { ok: true },
      200,
      {
        "set-cookie": clearCookie("session", {
          path: "/",
        }),
      },
    ),
);

Streaming Responses

import { api, createRouter, stream } from "typedapi.ts";

const encoder = new TextEncoder();

const downloadReport = api(
  { method: "GET", path: "/reports/daily.csv" },
  async () => {
    const body = new ReadableStream({
      start(controller) {
        controller.enqueue(encoder.encode("date,revenue\n"));
        controller.enqueue(encoder.encode("2026-03-18,18200\n"));
        controller.enqueue(encoder.encode("2026-03-19,19450\n"));
        controller.close();
      },
    });

    return stream(body, 200, {
      "content-disposition": "attachment; filename=daily.csv",
    });
  },
);

export default createRouter([downloadReport]);

SSE (Server-Sent Events)

import { api, createRouter, sse } from "typedapi.ts";

async function* salesFeed() {
  yield { store: "tokyo", total: 1280 };
  yield { store: "osaka", total: 1315 };
  yield { store: "nagoya", total: 1272 };
}

const streamSales = api(
  { method: "GET", path: "/events/sales" },
  async () =>
    sse(salesFeed(), {
      "x-stream-name": "sales-feed",
    }),
);

export default createRouter([streamSales]);

Redirect Responses

import { api, createRouter, redirect } from "typedapi.ts";

const legacyRedirect = api(
  { method: "GET", path: "/old-path" },
  async () => redirect("/new-path"),
);

const autoRedirect = api(
  { method: "GET", path: "/go" },
  async () => new URL("https://example.com"),
);

export default createRouter([legacyRedirect, autoRedirect]);

Static File Responses

import { api, createRouter, file } from "typedapi.ts";

const serveFavicon = api(
  { method: "GET", path: "/favicon.ico" },
  async () => file("./public/favicon.ico"),
);

const serveWithType = api(
  { method: "GET", path: "/data.csv" },
  async () =>
    file("./exports/data.csv", {
      contentType: "text/csv",
      headers: { "content-disposition": "attachment; filename=data.csv" },
    }),
);

export default createRouter([serveFavicon, serveWithType]);

Middleware

Middleware signature: (next) => (params) => Response. Middleware can read request parameters, return early, or call next() to continue execution.

import { api, createRouter, Header, middleware } from "typedapi.ts";

const auth = middleware((next) =>
  async (params: { authorization: Header<string> }) => {
    if (!params.authorization?.startsWith("Bearer ")) {
      return new Response("Unauthorized", { status: 401 });
    }
    return next();
  },
);

const getSecret = api(
  { method: "GET", path: "/secret", middlewares: [auth] },
  async () => ({ secret: 42 }),
);

export default createRouter([getSecret]);

With the transformer enabled, the parameter type of the middleware() handler and the return type of the inner handler are automatically extracted at compile time just like api(). Parameter and response metadata declared in middleware are merged into the OpenAPI document of every endpoint that uses that middleware; if they duplicate route-level parameters, the route-level parameters take precedence; if response status codes overlap, the route-level responses take precedence.

middleware() also accepts { parameters, responses, inject, validate } as its second argument. parameters and responses feed OpenAPI metadata, while inject and validate are stored on the middleware as metadata for the runtime pipeline.

Multiple middlewares run in array order using the onion model, and each one can insert logic before and after next():

const timing: Middleware = (next) =>
  async (_params: {}) => {
    const start = Date.now();
    const res = await next();
    console.log(`${Date.now() - start}ms`);
    return res;
  };

const getUsers = api(
  { method: "GET", path: "/users", middlewares: [timing, auth] },
  async () => [{ id: 1 }],
);

Route Grouping

routes() groups multiple routes together, with support for shared prefixes and middlewares:

import { api, routes, createRouter, Header, type Middleware } from "typedapi.ts";

const auth: Middleware = (next) =>
  async (params: { authorization: string }) => {
    if (!params.authorization) {
      return new Response("Unauthorized", { status: 401 });
    }
    return next();
  };

const getUsers = api(
  { method: "GET", path: "/users" },
  async () => [{ id: 1 }],
);

const getItems = api(
  { method: "GET", path: "/items" },
  async () => [{ id: 2 }],
);

// Both /api/users and /api/items go through the auth middleware
const apiRoutes = routes({ prefix: "/api", middlewares: [auth] }, getUsers, getItems);

export default createRouter(apiRoutes);

When groups are nested, prefixes are combined and middlewares run from outermost to innermost:

const logging: Middleware = (next) =>
  async (_params: {}) => {
    console.log("request");
    return next();
  };

const v1Routes = routes({ prefix: "/v1", middlewares: [auth] }, getUsers);
// Final path: /api/v1/users
// Execution order: logging → auth → handler
const allRoutes = routes({ prefix: "/api", middlewares: [logging] }, ...v1Routes);

export default createRouter(allRoutes);

Route-group-level middleware runs before the middleware defined on the individual route itself:

const rateLimit: Middleware = (next) =>
  async (_params: {}) => next();

// Execution order: auth (from routes) → rateLimit (from route) → handler
const protectedRoutes = routes(
  { middlewares: [auth] },
  api(
    { method: "POST", path: "/orders", middlewares: [rateLimit] },
    async (params: { item: string }) => ({ item: params.item }),
  ),
);

For concerns that must run before route matching or automatic OPTIONS handling, use router-level middleware via createRouter(routes, { middlewares }). Router middleware signature: (request, next) => Response | Promise<Response>.

CORS

import { api, createRouter, cors, routes } from "typedapi.ts";

const health = api(
  { method: "GET", path: "/health" },
  async () => ({ status: "ok" }),
);

const apiRoutes = routes({ prefix: "/api" }, health);

export default createRouter(apiRoutes, {
  middlewares: [
    cors({
      origin: ["https://app.example.com"],
      credentials: true,
      maxAge: 3600,
    }),
  ],
});

cors() is a router-level middleware. Use it with createRouter(..., { middlewares: [cors()] }), not api({ middlewares }) or routes({ middlewares }).

It does not short-circuit preflight requests. createRouter() still produces the 204 preflight response and Allow header, and cors() always calls next() before appending CORS headers onto the response.

CorsOptions configuration:

Option Type Default Description
origin string | string[] | ((origin: string) => boolean) "*" Allowed origins
methods string[] ["GET", "HEAD", "PUT", "POST", "DELETE", "PATCH"] Allowed HTTP methods
allowHeaders string[] Allowed request headers (if unset, echoes Access-Control-Request-Headers)
exposeHeaders string[] Response headers exposed to the browser
credentials boolean Whether credentials are allowed
maxAge number Number of seconds to cache preflight responses

Compose Routers

import { composeHandlers, cors, createRouter } from "typedapi.ts";

const publicApi = createRouter([...publicRoutes], {
  middlewares: [cors({ origin: "*" })],
});

const internalApi = createRouter([...internalRoutes], {
  middlewares: [cors({ origin: "https://admin.example.com" })],
});

const app = composeHandlers(publicApi, internalApi);

export default app;

composeHandlers(...handlers) tries each handler in order and returns the first response whose status is not 404. If none match, it returns the framework's standard JSON 404 Not Found response.

Error Handling

Throwing HttpError in a handler or middleware returns a controlled error response. Any other uncaught exception is rethrown to the caller of createRouter() instead of being converted into a framework-generated 500.

import { api, createRouter, HttpError, Path } from "typedapi.ts";

const orders = new Map<number, { id: number; customer: string }>([
  [1, { id: 1, customer: "Acme Corp" }],
]);

const getOrder = api(
  { method: "GET", path: "/orders/:id" },
  async (params: { id: Path<number> }) => {
    const order = orders.get(params.id);
    if (!order) {
      throw new HttpError(404, "Order not found");
    }
    return order;
  },
);

export default createRouter([getOrder]);

HttpError constructor parameters:

Parameter Type Description
status number HTTP status code
body string | Record<string, unknown> Optional. Strings are converted to { message } JSON; objects are returned as-is; if omitted, there is no response body
headers Record<string, string> Optional. Custom response headers
throw new HttpError(403);
// → 403, no body

throw new HttpError(404, "User not found");
// → 404, { "message": "User not found" }

throw new HttpError(422, { message: "Validation failed", errors: ["field required"] });
// → 422, { "message": "Validation failed", "errors": ["field required"] }

throw new HttpError(401, "Unauthorized", { "WWW-Authenticate": "Bearer" });
// → 401, { "message": "Unauthorized" }, WWW-Authenticate: Bearer

Non-HttpError exceptions thrown in handlers or middleware are not swallowed by the framework. They propagate to the caller of createRouter() unchanged:

const crashRoute = api(
  { method: "GET", path: "/crash" },
  async () => { throw new Error("database failed"); },
);
// → rejects/throws Error("database failed")

Custom Error Handling

createRouter() supports an app-wide onError option, and routes() supports group-specific onError. Both callbacks receive (error, request). If a custom onError throws, that new error is passed through handleError(): HttpError becomes a response, and everything else continues to bubble up.

import { api, createRouter, handleError } from "typedapi.ts";

class ValidationError extends Error {}

const createUser = api({ method: "POST", path: "/users" }, async () => {
  throw new ValidationError("invalid payload");
});

export default createRouter([createUser], {
  onError: (error, request) => {
    if (error instanceof ValidationError) {
      return Response.json(
        { message: error.message, path: new URL(request.url).pathname },
        { status: 422 },
      );
    }

    return handleError(error);
  },
});

Use routes({ onError }) when different route groups need different error-handling strategies:

import { api, routes, createRouter, handleError, HttpError } from "typedapi.ts";

class ValidationError extends Error {
  fields: string[];
  constructor(fields: string[]) {
    super("Validation failed");
    this.fields = fields;
  }
}

const apiRoutes = routes(
  {
    prefix: "/api",
    onError: (error, request) => {
      if (error instanceof ValidationError) {
        return Response.json(
          { message: error.message, fields: error.fields },
          { status: 422 },
        );
      }
      // Fall back to framework HttpError handling and let unknown errors bubble up
      return handleError(error);
    },
  },
  api({ method: "POST", path: "/users" }, async (params: { name: string }) => {
    if (!params.name) throw new ValidationError(["name"]);
    return { id: 1, name: params.name };
  }),
);

export default createRouter(apiRoutes);

onError callback parameters:

Parameter Type Description
error unknown The captured exception
request Request The current request object

Route groups without onError, as well as standalone routes not included in any routes(), fall through to createRouter's onError when one is configured. If createRouter also omits onError, the default fallback logic applies: HttpError becomes the matching response, and everything else is rethrown. handleError is exported as the default HttpError handler and can be used inside custom onError implementations when you want to preserve that behavior. The same handleError rule also applies if an onError callback itself throws.

Dependency Injection

inject() is used to declare request-scoped dependencies. You can define resources with cleanup logic using an async generator (initialize before yield, clean up after yield), or define dependencies without cleanup using a regular async function. Annotate a handler parameter with Inject<typeof X> to have it injected automatically.

import { api, createRouter, inject, type Inject, type Path } from "typedapi.ts";

// Define dependency — generator pattern (with cleanup)
const db = inject(async function* () {
  const client = await connectDb();
  yield client;
  await client.close();
}); // Defaults to cache: true — reuses the same instance within a request

// Define dependency — plain async function (no cleanup)
const requestId = inject(async () => crypto.randomUUID());

// Annotate handler params with Inject<typeof X> for automatic injection
const getUser = api(
  { method: "GET", path: "/users/:id" },
  async (params: {
    id: Path<number>;
    db: Inject<typeof db>;
    requestId: Inject<typeof requestId>;
  }) => {
    console.log("Request:", params.requestId);
    return params.db.query("SELECT * FROM users WHERE id = $1", [params.id]);
  },
);

export default createRouter([getUser]);

At compile time, the transformer automatically recognizes Inject<typeof X> type annotations, extracts references to injectable variables, and injects them into the route configuration. At runtime, the framework automatically does the following for each request:

  1. Validate request-sourced params when the route defines validate
  2. Call the inject function to obtain dependency values
  3. Merge the dependency values into the handler's params
  4. Execute generator cleanup code in reverse order after the request finishes (even if the handler throws)

The runtime order is validate → inject → handler. If validation fails with 400, injectables are not resolved.

The cache option controls reuse within the same request:

  • cache: true (default): the same injectable is initialized only once per request, and all usages share the same instance
  • cache: false: the inject function is called again each time it is used

Typed Dependency Injection

inject() handlers can declare required request parameters using Path, Query, Header, Cookie, and Json type annotations, just like api() and middleware(), and can also declare possible error responses with JsonResponse. The transformer automatically extracts parameter and response metadata at compile time, and at runtime the framework passes parsed request parameters into the inject function. Parameter and response metadata from inject() are automatically merged into the OpenAPI document of routes that use that inject function (precedence: middleware < inject < route).

import {
  api,
  createRouter,
  HttpError,
  inject,
  type Header,
  type Inject,
  type JsonResponse,
  type Path,
} from "typedapi.ts";

const auth = inject(
  async (params: {
    authorization: Header<string>;
  }): Promise<JsonResponse<401, {}, { message: string }>> => {
    const token = params.authorization?.replace("Bearer ", "");
    if (!token) throw new HttpError(401, "Unauthorized");
    return { userId: token };
  },
);

const getUser = api(
  { method: "GET", path: "/users/:id", expose: true },
  async (params: {
    id: Path<number>;
    auth: Inject<typeof auth>;
  }) => {
    return { id: params.id, userId: params.auth.userId };
  },
);

export default createRouter([getUser]);

Nested injectables

An injectable can depend on other injectables via the inject option. Dependencies are resolved first and passed into the dependent injectable's handler via params:

import { inject, type Header, type Inject } from "typedapi.ts";

const db = inject(async function* () {
  const conn = await openDb();
  try {
    yield conn;
  } finally {
    await conn.close();
  }
});

const currentUser = inject(
  async (params: {
    authorization: Header<string>;
    db: Inject<typeof db>;
  }) => {
    return params.db.findUser(params.authorization);
  },
);
// With the transformer enabled, the `inject: { db }` option is injected automatically
// from the `Inject<typeof db>` pattern in the handler parameter type.

Within a single request resolution:

  • Shared dependencies are cached by Injectable instance (cache: true by default), so a dependency referenced by multiple dependents is resolved only once.
  • Cleanup runs in reverse construction order - dependent injectables clean up before their dependencies.
  • Circular dependencies throw Error("Circular injectable dependency detected") at resolve time.
  • Dependency values take priority over same-named request params when merged into the injectable handler's params.

Nested injectable metadata (parameters / responses) also flows into the OpenAPI document - all parameters and responses declared along the dependency chain are collected for every route that uses the top-level injectable (directly or via middleware).

Compile-Time Parameter Metadata Injection

With ts-patch enabled, place the custom transformer before typia. At compile time, it directly analyzes the type of the first parameter of an api() handler and injects parameter metadata literals into the parameters field of api()'s third argument; it also analyzes the JsonResponse return type and injects response metadata literals into the responses field. It also analyzes the type of the first parameter of the inner handler returned by the middleware() outer handler, injects parameter metadata literals into the parameters field of middleware()'s second argument, and extracts response metadata from the inner handler's return type into responses. If parameters or responses are already provided manually, they are not overwritten. The transformer still does not generate validate; runtime validators must be passed explicitly.

{
  "compilerOptions": {
    "plugins": [
      { "transform": "typedapi.ts/transform" },
      { "transform": "typia/lib/transform" }
    ]
  }
}

Parameter Metadata (OpenAPI)

Wrapper metadata for Path / Query / Header / Cookie / Json is automatically extracted and injected at compile time. There is no need to import typia or use ParamsSchema. The type syntax itself stays the same:

import {
  api,
  createRouter,
  Cookie,
  Header,
  Json,
  JsonResponse,
  Path,
  Query,
} from "typedapi.ts";

interface Product {
  id: number;
  name: string;
  price: number;
}

interface UpdateProductParams {
  id: Path<number, { title: "Product ID"; example: 42 }>;
  currency?: Query<
    string,
    {
      title: "Currency";
      description: "ISO 4217 currency code";
    }
  >;
  name: Json<string, { title: "Product name" }>;
  price: Json<
    number,
    {
      title: "Product price";
      description: "Integer price in cents";
      example: 9900;
    }
  >;
  authorization: Header<
    string,
    {
      alias: "Authorization";
      title: "Access token";
    }
  >;
  "x-api-version": Header<
    string,
    {
      title: "API version";
      deprecated: true;
      description: "Please migrate to URL versioning; this header will be removed in v3";
    }
  >;
  storeId: Cookie<
    string,
    {
      alias: "x-store-id";
      title: "Store ID";
    }
  >;
}

const updateProduct = api(
  { method: "PUT", path: "/products/:id", expose: true },
  async (params: UpdateProductParams): Promise<JsonResponse<200, {}, Product>> => {
    return { id: params.id, name: params.name, price: params.price };
  },
);

export default createRouter([updateProduct]);

Sources of OpenAPI parameter metadata:

  • title, description, alias, example, and deprecated are all read from the second generic parameter Meta of Path<T, Meta> / Query<T, Meta> / Header<T, Meta> / Cookie<T, Meta> / Json<T, Meta>
  • The compile-time transformer directly generates parameter metadata literals containing __entries and __body
  • Inject<typeof injectable> type annotations are automatically recognized by the transformer and converted into inject configuration, and do not appear in OpenAPI parameter documentation
  • Optional properties are not added to required

Operation Metadata

You can attach OpenAPI operation fields directly on api() route config. routes({ tags }) prepends shared tags and deduplicates them against route-level tags:

import { api, openapi, routes } from "typedapi.ts";

const getUser = api(
  {
    method: "GET",
    path: "/users/:id",
    expose: true,
    tags: ["users"],
    summary: "Get user",
    description: "Return a user by ID",
    operationId: "getUser",
    externalDocs: {
      url: "https://example.com/docs/users#get-user",
    },
  },
  async () => ({ id: 1 }),
);

const apiRoutes = routes({ prefix: "/api", tags: ["v1"] }, getUser);

const document = openapi({
  info: { title: "Users API", version: "1.0.0" },
  routes: apiRoutes,
});

Generating OpenAPI 3.1 Documents

openapi() traverses routes with expose: true and reads the parameter and response metadata automatically injected at compile time into the third argument of api(). For JsonResponse<Status, Headers, Body> (including unions), it automatically generates OpenAPI responses; if needed, you can still manually pass { parameters, responses } to override the default behavior:

import { api, openapi, type JsonResponse, type Json } from "typedapi.ts";

interface Order {
  id: number;
  customer: string;
}

interface Message {
  message: string;
}

interface CreateOrderParams {
  /** @title Customer name */
  customer: Json<string>;
}

const createOrder = api(
  { method: "POST", path: "/orders", expose: true },
  async (_params: CreateOrderParams): Promise<
    | JsonResponse<201, {}, Order>
    | JsonResponse<400, {}, Message>
  > => {
    return { id: 1, customer: "Acme Corp" };
  },
);

const document = openapi({
  info: {
    title: "Orders API",
    version: "1.0.0",
  },
  servers: [{ url: "https://api.example.com" }],
  routes: [createOrder],
});

The generated result is an OpenAPI 3.1 object and currently includes:

  • paths
  • operation-level tags, summary, description, operationId, deprecated, and externalDocs
  • path parameters (/orders/:id -> /orders/{id}; if parameters are absent, they are generated as a fallback automatically)
  • query / header / cookie parameters (from parameter metadata literals injected by the transformer)
  • JSON requestBody (from Json<T> fields)
  • JSON responses (from JsonResponse metadata injected by the transformer, also compatible with manually provided typia schemas)
  • components.schemas

If an exposed route has no response schema attached, openapi() generates a default empty 200 response for it.

Runtime Validation (Typia)

import typia from "typia";
import {
  api,
  createRouter,
  inject,
  type Inject,
  type Json,
  type Path,
  type RequestParams,
} from "typedapi.ts";

const db = inject(async () => connectDb());

type CreateUserParams = {
  id: Path<number>;
  body: Json<{ name: string }>;
  db: Inject<typeof db>;
};

const createUser = api(
  { method: "POST", path: "/users/:id" },
  async (params: CreateUserParams) => {
    return {
      id: params.id,
      name: params.body.name,
    };
  },
  {
    validate: typia.createValidate<RequestParams<CreateUserParams>>(),
  },
);

export default createRouter([createUser]);

Import typia in every file that uses runtime validation, and build validators manually with typia.createValidate<RequestParams<HandlerParamType>>().

RequestParams<T> removes Inject<> fields from the validator input, so Typia does not try to validate runtime-injected objects.

The runtime order is validate → inject → handler. Validation failures return 400 before injectables are resolved.

The third argument to api() uses the { validate, responses, parameters } shape.

About

Type-safe HTTP API framework with automatic OpenAPI generation

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors