TS API Spec Link to heading


TS-first API spec with superb DX Link to heading

Everything is autocompleted and type-checked.
As soon as the schema is satisfied, the error disappears.

No separate spec file.
No generation step.


The problem this targets Link to heading

Most teams still pay the same tax:

  • duplicated contracts (controllers, OpenAPI, generated clients)
  • contract drift between backend and frontend
  • stale generated code
  • runtime bugs shipped by “green” builds

The root cause is structural:
the contract is treated as an artifact, not as the boundary.


Benefits Link to heading

  • Maximum code sharing between nodes (assuming JS/TS)
  • Fully typed end-to-end, including error cases
  • Uniform error handling across server and client
  • Single source of truth: router and apiClient are generated from the same spec

Optional runtime validation can be enabled in non-prod without changing the contract.


Overlap with existing approaches Link to heading

This overlaps with:

  • OpenAPI + codegen
  • RPC frameworks
  • shared schema repos

Difference: those generate code from a spec.
Here, the spec is the code boundary.


Why this is especially leveraged in JS/TS Link to heading

JS has an unfair advantage:

  • same language on backend and frontend
  • TypeScript as the shared type system

That enables:

  • zero drift
  • zero regeneration
  • no stale specs

Example Link to heading

Declare your spec once:

export const authRouter = {
  register_post: {
    bodySchema: registerRequestSchema,
    responseSchema: z.object({ token: z.string() }),
    cbErrorSchema: registrationError,
  },
  login_post: {
    bodySchema: loginRequestSchema,
    responseSchema: z.object({ token: z.string() }),
    cbErrorSchema: loginErrors,
  },
  refreshToken_post: {
    headerSchema: authHeader,
    cbErrorSchema: z.null(),
    responseSchema: z.object({ token: z.string() }),
  }
} as const satisfies ApiSpec;

export const apiSpec = {
  api: {
    auth: authRouter,
    macro: macroRouter
  }
} as const satisfies ApiSpec; // key line (TS 5)

//Generate both router and client from the same spec:

const { router: apiRouter } = makeApi(apiSpec, {}, express.Router);

//Controller implementation is fully typed by construction:

makeController(
  authRouter.register_post,
  async ({ body: { email, name, password } }) => {
    const user = await registerUser(email, name, password);

    if (typeof user === 'string') return user; // typed error case

    return { token: jwtSign({ userId: user.id }) };
  }
);

//And the client derives from the same contract:

export const { apiClient } = makeApi(apiSpec, {
  baseUrl: 'http://localhost:3033'
});

If you violate the contract on either side, TypeScript complains immediately.


How it’s built Link to heading


What this optimizes for Link to heading

  • correctness over ceremony
  • DX over documentation
  • contracts that cannot drift by construction

If you can break the contract without TypeScript complaining, the setup is wrong.