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
- Parser types (the core mechanism):
http://djuleayo.com/posts/parser-types/ - TypeScript 5 + Vite for clean cross-module sharing
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.