Skip to content

Typed client from OpenAPI

This guide gives the API’s consumers — a frontend, another service — a client with typed paths, parameters, bodies and errors. It is generated from the OpenAPI document that Tetsu builds from your routes.

Tetsu has no RPC client that imports the server’s types, as Elysia’s Eden or Hono’s client do. The document is the boundary instead: a client in another repository or another language reads it without the server’s code, and a change to the API shows up as a diff of one file. The cost is one generation step.

Mount docs() next to your controllers, and the application serves its document at /openapi.json:

import { controller, createApp, httpError, route } from "@tetsujs/core";
import { docs } from "@tetsujs/openapi";
const usersController = controller("Users", () => ({
get: route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/users/:id"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/users/:id",
schema: {
params: z.object({ id: z.coerce.number() }),
response: { 200: User, 404: UserNotFound },
},
handler: (ctx: {
readonly out: Outgoing & DeclaredOutgoing<404 | 200>;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
}) => {
id: number;
name: string;
}

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly out: Outgoing & DeclaredOutgoing<404 | 200>;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
}
ctx
) => {
if (
ctx: {
readonly out: Outgoing & DeclaredOutgoing<404 | 200>;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
}
ctx
.
params: {
id: number;
}
params
.
id: number
id
!== 1) throw httpError(404, "USER_NOT_FOUND");
return {
id: number
id
: 1,
name: string
name
: "Ada" };
},
}),
}));
export const app = createApp({
routes: [usersController(), docs({
info: DocumentInfo

Title, version and the rest of the document's info block.

info
: {
title: string
title
: "Users API",
version: string
version
: "1.0.0" },
ui?: false | undefined

Which renderer the page bootstraps. Defaults to "scalar".

false serves the document without a page — what production wants on an origin that carries a session. The page runs its renderer on the application's origin, so a renderer that is not what it should be acts in the signed-in user's name there; the document alone runs nothing. uiPath, title and assets are then unused.

ui
: false })],
});

ui: false serves only the document; leave it out to also get a documentation page at /docs.

openapi-typescript turns the document into TypeScript types, from a file or from a URL:

Terminal window
bunx openapi-typescript http://localhost:3000/openapi.json -o src/api.d.ts

The output is types only, with a paths interface keyed by the document’s paths. It costs nothing at runtime.

openapi-fetch is a thin fetch wrapper typed by that paths interface:

import createClient from "openapi-fetch";
import type { paths } from "./api";
const
const api: Client<paths, `${string}/${string}`>
api
= createClient<paths>({
baseUrl?: string | undefined

set the common root URL for all API requests

baseUrl
: "https://api.example.com" });
const {
const data: {
id: number;
name: string;
} | undefined
data
,
const error: Readable<ErrorResponse<{
200: Json<{
id: number;
name: string;
}>;
404: Json<components["schemas"]["UserNotFound"]>;
422: Json<components["schemas"]["ValidationFailed"]>;
500: Json<components["schemas"]["InternalServerError"]>;
}, `${string}/${string}`>> | undefined
error
} = await
const api: Client<paths, `${string}/${string}`>
api
.GET("/users/{id}", {
params: {
path: {
id: number;
};
}
params
: {
path: {
id: number;
}
path
: {
id: number
id
: 42 } },
});
if (
const error: Readable<ErrorResponse<{
200: Json<{
id: number;
name: string;
}>;
404: Json<components["schemas"]["UserNotFound"]>;
422: Json<components["schemas"]["ValidationFailed"]>;
500: Json<components["schemas"]["InternalServerError"]>;
}, `${string}/${string}`>> | undefined
error
) {
const code =
const error: Readable<ErrorResponse<{
200: Json<{
id: number;
name: string;
}>;
404: Json<components["schemas"]["UserNotFound"]>;
422: Json<components["schemas"]["ValidationFailed"]>;
500: Json<components["schemas"]["InternalServerError"]>;
}, `${string}/${string}`>>
error
.
error: "INTERNAL_SERVER_ERROR" | "USER_NOT_FOUND" | "VALIDATION_FAILED"
error
;
const code: "INTERNAL_SERVER_ERROR" | "USER_NOT_FOUND" | "VALIDATION_FAILED"
console.log(
const code: "INTERNAL_SERVER_ERROR" | "USER_NOT_FOUND" | "VALIDATION_FAILED"
code
);
} else {
console.log(
const data: {
id: number;
name: string;
}
data
.
name: string
name
);
}

The path is checked against the document, id must be a number, and data is the 200 body. A wrong path or a missing parameter is a compile error.

error is the body of any other status, typed as the union of what the document lists for the operation. That includes the failures the framework answers by itself, such as 422 for a validation failure and 500. Branch on the error code, not on the message, which is written for people and may change. See Errors.

An error that is thrown but not declared is not in the document. A route lists its own in its response map, as 404 above. A hook that refuses describes its refusal with documented() or secured(). A custom error format is described with errors.

A generator that writes a function per operation names it after the operationId. Tetsu builds it from the controller’s name and the route’s field: get in controller("Users", …) is usersGet. Renaming the controller renames the functions, so on a public API state the id on each route with docs: { operationId }. Two routes with the same id are an error when the document is built. See Operation ids.

@hey-api/openapi-ts generates the types and a function per operation:

Terminal window
bunx @hey-api/openapi-ts -i http://localhost:3000/openapi.json -o src/client

openapi-fetch keeps calls in the shape of HTTP; an SDK hides them behind functions. Which to use is a matter of taste.

The client belongs to its consumers: the frontend’s repository, or a package of its own in a monorepo. The server only hands over the document, either served, as above, or written to a file and committed. A committed file makes every API change a visible diff and allows the CI check below.

openapi() builds the document without serving it. The script imports the application, not main.ts, so nothing starts listening:

scripts/openapi.ts
import { openapi } from "@tetsujs/openapi";
import { app } from "../src/app";
const {
const document: OpenApiDocument
document
,
const warnings: readonly GeneratorWarning[]
warnings
} = openapi(app, {
info: DocumentInfo

Title, version and the rest of the document's info block.

info
: {
title: string
title
: "Users API",
version: string
version
: "1.0.0" } });
for (const
const warning: GeneratorWarning
warning
of
const warnings: readonly GeneratorWarning[]
warnings
) console.error(`${
const warning: GeneratorWarning
warning
.
route: string

The route the warning is about, as GET /path — empty for a warning about the document as a whole, such as a described tag nothing uses.

route
}: ${
const warning: GeneratorWarning
warning
.
message: string
message
}`);
if (
const warnings: readonly GeneratorWarning[]
warnings
.
length: number

Gets the length of the array. This is a number one higher than the highest element defined in an array.

length
> 0) process.exit(1);
await Bun.write("openapi.json", `${JSON.stringify(
const document: OpenApiDocument
document
, null, 2)}\n`);

A warning means part of a route could not be described, most often a schema from a validator that emits no JSON Schema. The script fails rather than write an incomplete document. Keeping the application apart from main.ts is the layout Structuring an application describes.

With the document committed, CI regenerates it and fails when it differs from the committed file:

Terminal window
bun scripts/openapi.ts
git diff --exit-code openapi.json

A pull request that changes the API without updating the document fails, and one that updates it shows the contract change next to the code. A tool such as oasdiff can list which changes break existing clients.

To check the other direction, that real responses match the document, use assertDescribed in tests.