Skip to content
Contract-firstShow, don't tell

Contract-First Without the Codegen Dance: OpenAPI, Typed Client, and Contract Tests From One Definition

One app.route({...}) projects into generateOpenAPI(app), createClient(app), and runContractTests(app), plus pnpm gen for a Hey API typed fetch SDK your frontend can import. With pictures.

Devlin Duldulaosoftware engineer & published book author12 min read

Hi, I'm Devlin. Ten years of fullstack work. I have, in my career, personally maintained a hand-written openapi.yaml in a monorepo, and I have personally been the reason it was three sprints out of date. I've also been the person who shipped a frontend that called POST /book when the backend had renamed it to POST /books the week before. So when I tell you the codegen dance is a real problem, please understand: I am one of the dancers.

The launch post promised you that one app.route({...}) call is the source of truth for validation, types, OpenAPI, the typed client, and contract tests. This post walks one route into OpenAPI, the typed client, and contract tests. We're going to define a single route, project it into all three artifacts on disk and in tests, then run pnpm gen and use the typed SDK from a separate Next.js frontend. The route remains the source, so there is no YAML copy to edit or version separately.

The one route

This file is the entire input. Everything that follows derives from it and changes only when the route changes. That is what "single source of truth" means in practice.

apps/api/src/routes/books.tsapps/api/src/app.ts
ts
// apps/api/src/routes/books.ts
import { z } from "zod";
import { app } from "../app.ts";

const Book = z.object({
  id: z.string(),
  title: z.string(),
  author: z.string(),
  publishedYear: z.number().int().optional(),
});

const Problem = z.object({
  type: z.url(),
  title: z.string(),
  status: z.number().int(),
  detail: z.string().optional(),
});

app.route({
  method: "GET",
  path: "/books/:id",
  operationId: "getBookById",
  summary: "Fetch a book by id",
  tags: ["books"],
  request: {
    params: z.object({ id: z.string().min(1) }),
  },
  responses: {
    200: { description: "Found", body: Book },
    404: { description: "Not found", body: Problem },
  },
  handler: async ({ params }) => {
    if (params.id === "missing") {
      return {
        status: 404,
        body: {
          type: "https://example.com/errors/not-found",
          title: "Not Found",
          status: 404,
        },
      };
    }
    return {
      status: 200,
      body: { id: params.id, title: `Book ${params.id}`, author: "Unknown" },
    };
  },
});
● apps/api/src/routes/books.ts, saved

One route, two declared responses (200 and 404), each with a real Zod schema. Hold that file in your head, we'll come back to it three times.

Three projections, one input

1

generateOpenAPI(app), the spec is a function of the routes

app.route({...})generated/openapi.json

The registered routes produce the OpenAPI document as a pure function. Call generateOpenAPI(app, ...), get a fully-formed RFC 3.1 document back, write it wherever you want it.

apps/api/scripts/dump-openapi.ts
ts
// apps/api/scripts/dump-openapi.ts
import { writeFileSync } from "node:fs";
import { generateOpenAPI } from "@daloyjs/core/openapi";
import { app } from "../src/app.ts";

const doc = generateOpenAPI(app, {
  info: { title: "Bookstore API", version: "1.0.0" },
  servers: [{ url: "http://localhost:3000" }],
});

writeFileSync("generated/openapi.json", JSON.stringify(doc, null, 2));
tsx scripts/dump-openapi.ts ✓ wrote 14 KB

The proof is one jq away:

bash
$ jq '.paths."/books/{id}".get | {operationId, responses: (.responses | keys)}' \
    generated/openapi.json
{
  "operationId": "getBookById",
  "responses": ["200", "404"]
}

The operationId on the route became the operationId in the spec. The set of declared responses became the set of documented responses. There is no second list to update.

2

createInProcessClient(app), the typed client lives in the same monorepo

app.route({...})ClientFor<App>

createInProcessClient<A extends App>(app) returns an object keyed by every operationId you defined, with full input/output type narrowing per status. The classic use for it is "in-process integration tests": requests traverse app.fetch automatically, giving you a real end-to-end test without a socket:

apps/api/tests/books.in-process.test.tsapps/api/src/routes/books.ts
ts
// apps/api/tests/books.in-process.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { createInProcessClient } from "@daloyjs/core/client";
import { app } from "../src/app.ts";

// In-process: route the client's fetch straight into the app. No socket,
// no port, no flaky CI. Same validation, same response shape as production.
const client = createInProcessClient(app);

test("getBookById - 200 has a typed body", async () => {
  const res = await client.getBookById({ params: { id: "42" } });
  assert.equal(res.status, 200);
  if (res.status === 200) {
    // res.body is { id: string; title: string; author: string; publishedYear?: number }
    assert.equal(res.body.id, "42");
  }
});

test("getBookById - 404 has the Problem body", async () => {
  const res = await client.getBookById({ params: { id: "missing" } });
  assert.equal(res.status, 404);
  if (res.status === 404) {
    assert.equal(res.body.status, 404);
    // @ts-expect-error - title belongs to Book, not Problem
    res.body.title;
  }
});
✓ node --test, 2 passing

The two things I want you to notice in that snippet are also the two things I celebrate every time I see them at work. First, the res.body inside the 200 branch is narrowed to the Book shape, not the union of every declared response, the actual 200 one. Second, the @ts-expect-error comment in the 404 branch passes: trying to read title from a Problem is a compile error, by construction.

3

runContractTests(app), the guardrails you forgot to write

app.route({...}){ ok, checked, issues }

runContractTests(app, opts) walks every registered route and checks the boring rules that turn into 3am bugs: every route has a unique operationId, every route declares at least one response, declared examples validate against their declared schema, and safe methods don't carry request bodies unless you explicitly allow it.

apps/api/tests/contract.test.ts
ts
// apps/api/tests/contract.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { runContractTests } from "@daloyjs/core/contract";
import { app } from "../src/app.ts";

test("every route is a good citizen", async () => {
  const report = await runContractTests(app, {
    requireOperationId: true,        // default
    allowBodyOnSafeMethods: false,   // default
  });

  if (!report.ok) {
    // Pretty failure: which route, which check, which message.
    for (const issue of report.issues) {
      console.error(
        `  ✗ ${issue.method} ${issue.path}: ${issue.message} (${issue.code})`,
      );
    }
  }

  assert.ok(report.ok, `${report.issues.length} contract issue(s) found`);
  console.log(`✓ ${report.checked} routes checked`);
});
✓ 12 routes checked, all clean

This is the test I add first to every new project, before any feature tests. It catches the "oh, two routes accidentally share an operationId because copy-paste" bug that ruins your generated SDK before it's even generated. Cheap to add; costly when you skip it.

The codegen dance, but the dance is one command

The three projections above stay in your repo. For consumers in another repo, team, or language, pnpm gen produces the client artifacts. It uses two scripts and one parent script:

apps/api/package.jsonopenapi-ts.config.ts
json
// apps/api/package.json
{
  "scripts": {
    "gen:openapi": "tsx scripts/dump-openapi.ts",
    "gen:client":  "openapi-ts",
    "gen":         "pnpm gen:openapi && pnpm gen:client"
  }
}
scripts.gen = gen:openapi && gen:client

gen:openapi calls the dump script you already saw. gen:client hands that JSON to Hey API's @hey-api/openapi-ts via this tiny config:

openapi-ts.config.ts
ts
// openapi-ts.config.ts
import { defineConfig } from "@hey-api/openapi-ts";

export default defineConfig({
  input: "./generated/openapi.json",
  output: { path: "./generated/client", postProcess: ["prettier"] },
  plugins: ["@hey-api/client-fetch", "@hey-api/sdk", "@hey-api/typescript"],
});
hey-api · plugins: client-fetch, sdk, typescript

Generate the client:

bash
$ pnpm gen
> apps/api gen
> pnpm gen:openapi && pnpm gen:client

 wrote generated/openapi.json
 wrote generated/client/index.ts
 wrote generated/client/sdk.gen.ts
 wrote generated/client/client.gen.ts
 wrote generated/client/types.gen.ts

One command replaces the old Java invocation, language flags, Docker wrapper, and post-processing script. generated/client/ is a real, typed, tree-shakeable fetch SDK that you can import from anywhere you can import TypeScript.

Using it from a separate Next.js frontend

The frontend completes the loop. It lives in a different app (apps/web in a monorepo, or a totally separate repo with the client published to a registry, your call). It imports the generated SDK and calls it like any other module. Pay attention to the call shape, path for path params, { data, error, response } destructure for results:

apps/web/app/books/[id]/page.tsxapps/web/api-client/index.ts
tsx
// apps/web/app/books/[id]/page.tsx
import { notFound } from "next/navigation";
import { client, getBookById } from "@/api-client";

client.setConfig({
  baseUrl: process.env.NEXT_PUBLIC_API_URL!,
});

export default async function BookPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  const { data, error, response } = await getBookById({ path: { id } });

  if (response.status === 404) notFound();
  if (error) throw error;

  return (
    <article>
      <h1>{data.title}</h1>
      <p className="text-muted-foreground">by {data.author}</p>
      {data.publishedYear ? <p>{data.publishedYear}</p> : null}
    </article>
  );
}
next 16 · server component · typed

That is a Next.js 16 server component, with shadcn-style classes, calling a typed SDK that was generated from a Zod schema on the other side of the monorepo. data.title is a string. data.publishedYear is a number | undefined. If the backend renames title to name, this file refuses to compile, and the frontend developer finds out before the PR even opens, not after the user complains.

The diff that doesn't exist

Change one field in the route and watch what moves on its own.

apps/api/src/routes/books.ts
diff
// One change in the route file...
- 200: { description: "Found", body: Book },
+ 200: { description: "Found", body: Book.extend({ rating: z.number().min(0).max(5) }) },

// ...and immediately, without writing types or running codegen by hand:
//
//   - generated/openapi.json gains `rating` in the 200 schema
//   - createClient(app) narrows res.body to include rating
//   - the contract test still passes (operationId, response set unchanged)
//   - the frontend's getBookById() refuses to compile until you render it
//
// That last bullet is the reason this workflow matters.
◐ src/routes/books.ts, modified

The route diff is two lines. Your openapi.yaml, client types, contract tests, frontend imports, and "types package" stay at zero lines, because those files don't exist as separate truths anymore. Commit the route change and run pnpm gen to regenerate the SDK.

The four-step checklist for new projects

If you're bootstrapping a contract-first stack today, this is the order I'd do it in, having now done it more times than I care to admit:

  1. Write the smallest route with real request and responses schemas. Don't hand-roll types anywhere.
  2. Add the contract test (runContractTests(app)) before any feature test. It costs nothing and catches the bugs that hurt the most.
  3. Add the in-process client test (createInProcessClient(app)). You now have integration coverage without a server.
  4. Wire pnpm gen and import the generated SDK in your frontend. Delete any hand-written API client. (This is the dopamine part.)

Limits

Code generation has had a bad reputation in the JS world for a long time, and it earned that reputation. Most pipelines were brittle, slow, and produced types that looked like they were translated from another language by someone who didn't want to be there. The workflow works because three mature pieces line up: Standard Schema lets the route own validation and types, OpenAPI 3.1 is the lingua franca for handing that to the outside world, and Hey API takes that spec and produces a typed fetch SDK that doesn't look like a translation. We just connected them.

If you want to go deeper, the typed client docs, the OpenAPI docs, and the testing docs each cover one of these three projections in detail. Or run pnpm create daloy@latest, point pnpm gen at it, and watch the dance turn into a single key press.

Go delete a hand-written API client.

Devlin