A typed backend client generated from an OpenAPI spec. One op() per
operation; the request body, query, path params and response type are read out
of the spec's own types, so a backend rename lands as a compile error instead
of a 422 at runtime.
No runtime validation, no generated fetch soup, no class-per-tag. The whole
runtime is one createClient().
npm i openapi-opnpx openapi-op http://localhost:8000/openapi.json --out lib --prefix /api/v1Writes, every run:
| file | what |
|---|---|
lib/schema.ts |
openapi-typescript output, verbatim |
lib/endpoints.ts |
the callable client, grouped by the first path segment |
lib/operations.ts |
LoginRequest / LoginResponse, one pair per operation |
docs/api.md |
every path, verb, request and response (--no-docs to skip) |
Written once, then yours: lib/api.ts (where the base URL and the auth
header come from) and lib/api-types.ts (the helpers bound to your spec).
Add it to package.json:
"scripts": {
"codegen": "openapi-op ${API_URL:-http://localhost:8000}/openapi.json --out lib --prefix /api/v1"
}import { auth, members } from "@/lib/endpoints";
const { access_token } = await auth.login({ body: { email, password } });
const { items } = await members.list({ query: { limit: 20 } });
const rows = await members.listItems(); // paginated -> .items sibling
await members.changeRole({ params: { user_id }, body: { role: "admin" } });params is required when the path has {...}, body and query when the
spec marks them required (a requestBody?: or an all-optional query stays
optional), and a key the route has no use for is absent from the signature
entirely. The argument type resolves to an object literal rather than to its
own alias, so hovering a call lists the fields it wants.
lib/api.ts, scaffolded on first run:
import { createClient } from "openapi-op";
import type { paths } from "./schema";
export { ApiError } from "openapi-op";
export const { api, op } = createClient<paths>({
baseUrl: () => process.env.API_ORIGIN ?? "http://localhost:8000",
prefix: "/api/v1",
headers: async () => ({ authorization: `Bearer ${await token()}` }),
});baseUrl and headers are functions, and may be async, because on a server
the origin and the token are usually per-request (a Host header, a session
cookie) rather than known at import time.
| option | |
|---|---|
baseUrl |
origin, without the prefix. string or () => string | Promise<string> |
prefix |
the path prefix spec keys carry that baseUrl does not — /api/v1 |
headers |
per-request headers; where the Authorization comes from |
parseError |
defaults to reading { error: { code, message } } |
export const { api, op } = createClient<paths>({
baseUrl: async () => process.env.API_ORIGIN ?? `https://${(await headers()).get("host")}`,
prefix: "/api/v1",
headers: async () => {
const token = (await cookies()).get("session")?.value;
return token ? { authorization: `Bearer ${token}` } : {};
},
});Put import "server-only" at the top of that file and the token never has a
path to a browser bundle.
Every non-2xx throws ApiError with status, code, message and the raw
body. 204 resolves to undefined.
try {
await members.invite({ body: { email } });
} catch (err) {
if (err instanceof ApiError && err.status === 409) return { taken: true };
throw err;
}lib/api-types.ts binds the generic helpers to your spec:
type Body = Req<"/api/v1/auth/login", "post">; // Credentials
type Out = Res<"/api/v1/auth/login", "post">; // Token
type Q = Query<"/api/v1/members", "get">; // { limit?, offset? }A plain data shape — a component prop, a value just held or passed along,
with no call site of its own — gets its own named export in the generated
operations.ts, one per component schema:
import type { RoleOut } from "@/lib/operations";Where a wrapper has to restate a route's types — a Server Action most of all —
name the route's own type out of the generated operations.ts instead:
import type { RegisterRequest, RegisterResponse } from "@/lib/operations";
export async function signupAction(
body: RegisterRequest,
): Promise<Result<RegisterResponse>> {
return run(() => auth.register({ body }));
}The names come from the spec's summary, the same string that becomes the
method name — Login is LoginRequest / LoginResponse, Change Role is
ChangeRoleRequest. A route with no body has no …Request, and a collision
keeps its verb (PostPingResponse). Every operation gets a pair, including
the ones outside --prefix that you reach with api().
Naming the schema by hand is the thing to avoid: reaching for the component
schema's own name (Register) where a route's …Request/…Response was
meant is a second, independent claim about the same route, and structural
typing lets a wrong one through — it annotates a Credentials body without
complaint, because the extra field is optional and TypeScript sees a subtype.
A generated name cannot drift; a rename in the spec is a compile error rather
than a 422.
ReqOf / ResOf / ArgsOf read the same types off an endpoint function, for
code that is generic over one and has no single name to import:
const wrap = <F extends typeof auth.login>(f: F) => (body: ReqOf<F>) => f({ body });A plain data shape a component holds (CourseOut[]) still imports its named
type from operations.ts — there is no endpoint there to read from, so no
Req/Res applies, only the schema's own name.
Generated names come from the spec, so they are only as good as it is:
- Groups are the first path segment after the prefix —
/api/v1/members/*becomesexport const members. - Method names come from
summary, minus the group word:List Membersinmembersismembers.list, butChange RolestayschangeRole. A collision keeps its verb. - Pagination: a response named
Paginated_X_also gets a…Itemssibling returning the rows. - Routes outside
--prefix(/healthz) are skipped — call those withapi().
api(path, init) is the untyped escape hatch for anything the spec does not
describe.
Writing the spec on the backend? See docs/spec-authoring.md — the Swagger/OpenAPI rules that decide what the generated names and types come out as.
const tenant = await api<Tenant>("/tenant");MIT.