Last night I published version 0.1.4 of koa-okapi-router - a small project that grew out of repeated frustration while working on a microservice-heavy platform with a lot of HTTP services.
The codebase is written in TypeScript. While Koa itself (and most popular middleware) have decent type definitions, the central piece - the Koa Context - effectively remains untyped at the edges. Requests arrive as loosely structured blobs where path params, query params, and request bodies all default to any or unknown.
In JavaScript, where Koa comes from, this is fine. In TypeScript, it sticks out. Even in codebases with otherwise good type hygiene, this creates a blind spot right at the system boundary.
The other recurring pain point is OpenAPI and Swagger. It introduces yet another source of truth that has to be kept in sync with runtime behavior. In practice, this often means large chunks of YAML embedded in comments, with no guarantees that what’s documented actually matches what the handler does.
Looking at ecosystems like Fastify(with Zod-based schemas), it’s hard not to notice the luxuries they enjoy: schemas that drive both validation and typing, and OpenAPI output that naturally falls out of the same definitions.
Yes, another option is to migrate to Fastify. But in the real world of tight deadlines, existing middleware stacks, and teams that already know Koa, replacing the entire HTTP layer is a hard sell. It means retraining, rewiring infrastructure, and taking on risk - all to fix something that:
- A) feels like it should be solvable without a rewrite.
- B) doesn't directly help in meeting any project goals or deadlines.
koa-okapi-router is my attempt to solve that problem where it actually exists.
The essence of the problem
Let’s re‑state the issues mentioned above, as they tend to show up in the day‑to‑day reality of maintaining a Koa service.
They are really two separate, but tightly connected, concerns. They both describe the shape of input and output, but they do it for two very different purposes.
OpenAPI as an external artifact
In practice, OpenAPI in many Koa projects often looks like this:
// routes/user.ts
/**
* @swagger
* /users/{id}:
* get:
* summary: Get user by id
* parameters:
* - in: path
* name: id
* schema:
* type: string
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
*/
router.get('/users/:id', async (ctx) => {
ctx.body = await getUser(ctx.params.id)
})
- OpenAPI is provided via
swagger-jsdoc - the spec lives in large YAML blobs embedded in comments
- those comments suddenly carry semantic meaning
- and the same information is duplicated between code and documentation
That alone is uncomfortable. And even though the example above is much smaller than an any real-world route - which usually have more complex request bodies, as well as multiple responses and path/query parameters - it's already more noise in my editor than I want.
But it also raises questions that – usually – surprisingly few people on a team can answer confidently:
- Where does this spec actually come from?
- Is it written by hand or generated?
- If it’s generated, when does that happen?
- Does it depend on runtime inspection?
- Does the build have to preserve comments for this to work?
So even before correctness enters the picture, the origin and lifecycle of the API contract is often unclear.
To be clear – this is not a jab at
swagger-jsdoc, its maintainers, or the hard work that went into it. It has over a million weekly downloads and it does its job. It just never quite sat right with me.
Type correctness at the system boundary
Separately (but related) there’s the issue of type correctness.
Consider the handler above. From TypeScript’s point of view:
ctx.params.id // any
ctx.request.body // any
ctx.body // any
There is no connection between the Swagger spec and the actual code. Nothing enforces at compile time that:
- the handler returns what the spec claims
- the request body matches what the spec describes
- or that path/query parameters are handled correctly
At best, you have documentation. At worst, you have documentation that looks authoritative but is quietly wrong.
OpenAPI and language-level type safety are two different concerns - but they should be derived from the same source of truth: the shape of input and output.
What I wanted was a way that feels natural to the language to declare that single source of truth once, and have it:
- generate correct OpenAPI
- and inform the TypeScript compiler about what the handler is actually allowed to do
So that the compiler simply won’t let you violate the stated schema.
The core idea
koa-okapi-router is a small routing layer for Koa that lets you define routes once and get:
- type-safe handlers
- structured schemas
- and correct OpenAPI output
- A new interface that is wrapped around your existing
KoaRouter, without replacing it.
without:
- replacing Koa
- rewriting your app
- migrating everything in one go
You can adopt it route by route, without punishing your Koa application for existing.
What it looks like in practice
With koa-okapi-router, the same route might look more like this:
router.get(
'/users/:id',
// This new second argument is now the canonical schema
// definition for this route.
{
summary: 'Get user by id',
tags: ['Users'],
params: {
id: z.string(),
},
response: {
200: {
description: 'OK',
schema: UserSchema // Zod Object
})
},
},
// The route handler function stays the same, except that
// ctx is now type-safe:
//
// ctx.params is now { id: string }
// ctx.body is now z.infer<typeof UserSchema>
async (ctx) => {
ctx.body = await getUser(ctx.params.id)
}
)
And the OpenAPI definition is then up for grabs directly from the router instance:
const openApiObject = router.openapiJson()
The important part isn’t the exact API shape.
The important part is that the OpenAPI output and the handler types are derived from the same definition.
No comments. No duplication. No yaml-entangled parallel universes.
Designed to co-exist with OpenAPI schemas from other sources
Adding the OpenAPI documentation from an OkapiRouter instance to an existing OpenAPIObject
is as simple as this:
// Produce the openapi json in the same way as before
const swaggerJson = swaggerJsDoc(jsDocSpec)
// But before serving it, let the OkapiRouter produce a version
// of it that merges its own documentation right into it.
const openApiObject = router.extendOpenApiJson(swaggerJson)
More Examples
More examples can be found in the project README.md at either:
Status and next steps
I opened this post by mentioning the version number - 0.1.4. In other words, it's still a package in its early stages.
That said, it's not a toy and it has passed the stage of being a spike. The core ideas have settled, the overall direction is clear, and the library is already being used in the "real world".
But before this project deserves to be slapped with a 1.0.0 label, there are still a couple of things that I want to get right. The list of such things currently looks something like this:
- Arriving at a stable API for schema-definitions
- Opt-in runtime request schema-validation
- Opt-in runtime response schema-validation
- More granular control over existing features and their behavior via configuration, such as enabling, disabling or limiting the automatic linking of component entities.
- An opt-in alternative route handler interface, that enforces
return-ing a valid response rather then modifying thectxinstance.
And from there and onwards, there are plenty of things to tick off before being "feature complete". Such as:
- Fully cover all the commonly used areas of the OpenAPI spec
- Include request and response headers in both schema definition and ctx type definition
- Support multiple content types, not just
application/json.
In the meantime, nothing would delight me more than if you took the router out for a spin and report back on how it felt.
Feedback, questions, suggestions (with or without sharp edges) are very welcome.
Top comments (0)