Generating code from the API spec

Jesse James Richard
|
|
9 min read
#Method
#Architecture
#APIs & MCP
#Keystone

Most of the code in this codebase was not written. It was produced. Giant Context runs on an apparatus that reads the API's description of itself and materializes the client side of the platform.

The API drives everything

The intent predates the repository. I wanted the API to drive everything, and the moment you commit to that, generation stops being optional. If the API is the single source of truth, then every place that truth appears (the typed client, the data hooks, the cache keys, the form validation, the route tables) has to be derived from it, because any hand-written copy is a second source of truth that drifts out of agreement.

The two formats in the chain are OpenAPI for the description and zod for the client schemas. Both are the obvious choice. OpenAPI is what a self-describing HTTP API emits and what every code generator already reads. Zod is what React form libraries already accept as a resolver, so a generated schema drops into a form with no adapter. Picking anything more interesting at either end would have meant writing the tooling that already exists for these two.

What a route declares

All of it starts in a route file. A route declares its own contract inline, as TypeBox schemas the framework validates against at the boundary.

The route declares its contract

export const POST = {  schema: {    operationId: "createOrganization",    tags: ["Organizations"],    summary: "Create an organization",    body: Type.Object({      name: Type.String({ minLength: 1, maxLength: 100 }),      slug: Type.String({        minLength: 1,        maxLength: 50,        pattern: "^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$",      }),      description: Type.Optional(Type.String()),    }),    response: { 201: organizationSchema },  },  handler: createOrganizationHandler,};

Fastify compiles that TypeBox object into a validator and runs it before the handler. A POST with a slug of My Org gets a 400 listing the failed constraint. The handler never executes.

The same object is registered with the swagger plugin, which walks every route at boot and assembles them into an OpenAPI document. The dev server serves it at /docs/json. Here is the route above, as it comes back:

The same route, served at /docs/json

"/organizations": {  "post": {    "operationId": "createOrganization",    "tags": ["Organizations"],    "summary": "Create an organization",    "requestBody": {      "content": {        "application/json": {          "schema": {            "type": "object",            "required": ["name", "slug"],            "properties": {              "name":        { "type": "string", "minLength": 1, "maxLength": 100 },              "slug":        { "type": "string", "minLength": 1, "maxLength": 50,                               "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$" },              "description": { "type": "string" }            }          }        }      }    }  }}

The JSON is the TypeBox object with different punctuation. Same field names, same bounds, same regex. Nobody transcribed one into the other, so there is no version of this where the docs describe a slug rule the server stopped enforcing.

The operation id carries the most weight of any field there. The generators use it as a name: the zod schema becomes createOrganization, the client method becomes api.createOrganization(), the hook becomes useCreateOrganization, and the cache key derives from it too. Change the string in the route file and all four change on the next pass. That is why the three arguments a form factory takes always refer to the same operation.

Ten small generators

The apparatus is around ten small programs, each owning one output family. One reads the spec and emits the typed client. Others emit the query cache keys, the data hooks, the route tables, the actions, the mocks and factories the tests run on, the merged locales. No single grand generator, just narrow ones, because narrow generators are easy to write, easy to debug, and cheap to add to.

Which means the work has one order and no shortcuts. Write the endpoint. Get the schema right, because the schema is the contract every generated artifact is cut from. Then run the generators and the client method, the hook, the cache key and the form validation all exist. Skip the schema, or leave a field loosely typed, and what comes out the far end is loose in exactly the same way. There is no stage after generation where a frontend developer tightens it up, because tightening it up would mean editing a generated file.

The endpoint has to be right first. Everything downstream of it is a copy.

Each generator is a smaller program than the word apparatus suggests. The hooks generator walks the operations in the spec and writes a hook per operation. Given the route from earlier, this is what it emits:

The hook generated for that route

// AUTO-GENERATED - DO NOT EDIT
export const useCreateOrganization = (  options?: UseMutationOptions<Organization, ApiError, CreateOrganizationBody>,) =>  useMutation({    mutationFn: (data: CreateOrganizationBody) =>      api.createOrganization(data),    ...options,  });

Every name in there traces back to the route. The hook is the operation id with a use prefix. The body type is the operation id with a Body suffix, and its fields are the TypeBox fields with their bounds intact. The return type is the response schema the route declared. The generator itself is a walk over the spec's operations, a string template per hook, and a file write.

The other generators do the same walk and write different templates. That is the whole trick.

One package for the outputs

Everything the generators produce lands in a single package named generated, and its layout is the platform's client surface at a glance:

packages/generated/src/

packages/generated/src/├── client/│   ├── api.ts          typed client + zod schemas│   ├── hooks.ts        data hooks│   ├── keys.ts         query cache keys│   ├── routes.ts       the console route table│   ├── menu.ts         navigation│   ├── types.ts│   ├── __mocks__/      test mocks│   └── __e2e__/        generated e2e scaffolds├── server/└── locales/

Nothing in that tree is edited by hand. A change made there survives until the next generation pass and then disappears, usually without anyone noticing it was the fix. When an output is wrong, the wrong thing is upstream: the route schema, or the template that read it.

Putting every output in one package also solved a problem that has nothing to do with generation. A monorepo of compartmentalized sub-packages needs some surface that cuts across all of them, because every app needs the same typed client, the same hooks, the same keys. The moment sub-packages import each other to share that surface, you are one refactor away from circular references. The generated package is the designated cross-cutter. Everything imports from it, and it imports from no one.

Running the generators in watch mode

For about an hour, early on, I ran the generators by hand. Then they became services. A tool runs when you call it. A service never stops. The dev environment runs every service hot, and the generators sit among the API and the web client in watch mode. Save a schema change and the API reloads, the spec updates, and regeneration follows in the same breath as the hot reload.

That choice paid immediately. January's receipts say the console grew from 25 routes to 74 in the first three weeks of the month. Write the backend, and half the frontend work is already done. The client, the hooks, the cache keys, and the validation for every new endpoint arrive the moment the endpoint does, and prototyping runs at the speed of the API instead of the speed of my typing.

There is a second beneficiary I did not build this for, and it may end up mattering more. Most of the code here is written by AI tooling, and the failure mode of a model working in an unfamiliar codebase is plausible invention. It writes a client method that looks right. It names a cache key the way a cache key is usually named. It reimplements a validation rule from the field names. Every one of those compiles and every one of them is a second source of truth.

None of it is available here. There is no hand-written client to guess at, because the client is generated and the method either exists or it does not. There is no cache key to name, because the key comes from the operation id. There is no validation to reinvent, because the schema arrives with the endpoint. A model cannot drift from the contract, since the only path to the API runs through artifacts the contract produced. Get the endpoint right and the machine is constrained to be right with it. Get it wrong and nothing downstream can quietly paper over it.

0

console routes at the start of January

0

console routes three weeks later

What it costs

The ledger has entries on both sides. Generators are code, and they break like code. A malformed schema can stall the whole chain, and debugging a generator means holding both the spec and the template in your head at once. The spec must stay healthy or nothing moves, which converts API discipline from a virtue into a dependency. And there is a habit to unlearn: the broken file in front of you is the one place you cannot fix it.

I pay those costs without complaint, because of what sits on the other side of the ledger. Most of the code in a codebase does not deserve to be written. It is derivable, and anything derivable that you write by hand becomes a copy you now maintain. Treat that code as a build artifact instead, and the surface you actually write shrinks to the parts that carry judgment, meaning the schema, the business logic, the tests, the things a machine cannot decide. The rest arrives the way compiled code arrives, correct by construction and disposable by design.

Where does the code come from? A running server describes itself, ten small programs read the description, and one package nobody wrote holds the result. None of those three parts is hard. Building them in that order is what makes the rest of the platform cheap.

#Method
#Architecture
#APIs & MCP

The console's router is a system I own outright, and there was never a bake-off. Next got the best idea in front-end routing right, that a folder tree...

Jesse James Richard

|

Jan 20, 2026
Read previous

Hiring an early engineer, or building something like this

Remote, Pacific time, full-time or contract. Get in touch.

Contact Jesse
Home
About
Contact
Sitemap
Privacy Policy
Terms of Service
Cookie Policy
Generating code from the API spec | Jesse James Richard