npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

typedapi.ts

v0.4.1

Published

Type-safe HTTP API framework with automatic OpenAPI generation

Readme

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() returns the standard (request: Request) => Promise<Response> signature, so you can deploy it to Cloudflare Workers by exporting it directly:

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

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

export default createRouter([health]);

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

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

const info = api(
  { method: "GET", path: "/info" },
  async (params: { [requestSymbol]: RequestContext }) => {
    const req = params[requestSymbol];
    return { url: req.url, method: req.method };
  },
);

export default createRouter([info]);

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.

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 }),
  ),
);

CORS

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

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

const apiRoutes = routes(
  {
    prefix: "/api",
    middlewares: [
      cors({
        origin: ["https://app.example.com"],
        credentials: true,
        maxAge: 3600,
      }),
    ],
  },
  health,
);

export default createRouter(apiRoutes);

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 |

Error Handling

Throwing HttpError in a handler or middleware returns a controlled error response. Any other uncaught exception is automatically converted into 500 Internal Server Error.

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 return 500 without exposing internal error details:

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

Custom Error Handling

routes() supports an onError option for customizing error handling at the route-group level. Different route groups can use 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 default handling for other errors (HttpError → matching response, others → 500)
      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(), are handled by createRouter's default fallback logic (HttpError -> corresponding response, everything else -> 500). handleError is exported as the default handler and can be called as a fallback inside custom onError implementations.

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]);

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.