Kaapi: A flexible, extensible backend framework for modern APIs with messaging, documentation, and type safety built right in.
This series is written for backend developers who love TypeScript and can appreciate Hapi’s design philosophy.
Validation… again?
Maybe you followed along with the previous article on request validation using Joi, ArkType, Valibot, or Zod.
You’ve got a shiny Kaapi app, routes are clean, validation is solid. Everything felt neat and reassuring.
You probably started with something like this:
import { z } from 'zod';
app
.base()
.zod({
params: z.object({
name: z.string().min(3).max(10)
})
})
.route({
method: 'GET',
path: '/hello/{name}',
handler: function (request, h) {
return `Hello ${request.params.name}!`;
}
});
And honestly?
That works perfectly fine.
Until the day your app stops being small.
At some point, routes start multiplying. You stop defining them inline. They live in their own files, grouped by feature. And suddenly, that nice little chain you had doesn’t quite fit anymore.
So the question becomes:
How do you keep validation close to your routes… when routes are no longer close to your app?
That’s what we’re solving here.
The new setup (you’ve probably done this already)
You want two things:
- routes defined independently
- a single place where they’re registered with
app.route(...)
And you still want:
- validation
- type safety
- freedom to choose your favorite validator
Let’s walk through it, one validator at a time.
Joi: the familiar path
If you’re using Joi, nothing changes. That’s intentional.
Install it:
joi
Then define a route the same way you would with Hapi:
import { Kaapi, KaapiServerRoute } from '@kaapi/kaapi';
import Joi from 'joi';
const app = new Kaapi({ /* ... */ });
const route: KaapiServerRoute<{ Params: { name: string } }> = {
method: 'GET',
path: '/hello/{name}',
options: {
validate: {
params: Joi.object({
name: Joi.string().min(3).max(10).required()
}),
},
},
handler: function (request, h) {
return `Hello ${request.params.name}!`;
}
};
No magic. No surprises.
If you’ve used Hapi before, this should feel like coming home.
Zod: validation that talks to TypeScript
Now let’s say you want strong type inference, without juggling generics.
Install:
zod@kaapi/validator-zod
First, extend Kaapi:
import { Kaapi } from '@kaapi/kaapi';
import { validatorZod } from '@kaapi/validator-zod';
const app = new Kaapi({ /* ... */ });
const start = async () => {
await app.extend(validatorZod);
};
start();
Now define your route:
import { withSchema } from '@kaapi/validator-zod';
import { z } from 'zod';
const route = withSchema({
params: z.object({
name: z.string().min(3).max(10)
})
}).route({
method: 'GET',
path: '/hello/{name}',
handler: function (request, h) {
return `Hello ${request.params.name}!`;
}
});
No generics.
Your types come straight from the schema.
You write validation once and TypeScript follows along.
Valibot: same idea, different flavor
Valibot plays in the same league as Zod, with a slightly different syntax.
Install:
valibot@kaapi/validator-valibot
Extend Kaapi:
import { Kaapi } from '@kaapi/kaapi';
import { validatorValibot } from '@kaapi/validator-valibot';
const app = new Kaapi({ /* ... */ });
const start = async () => {
await app.extend(validatorValibot);
};
start();
Then define the route:
import { withSchema } from '@kaapi/validator-valibot';
import * as v from 'valibot';
const route = withSchema({
params: v.object({
name: v.pipe(v.string(), v.minLength(3), v.maxLength(10))
})
}).route({
method: 'GET',
path: '/hello/{name}',
handler: function (request, h) {
return `Hello ${request.params.name}!`;
}
});
ArkType: schemas you can read
Install:
arktype@kaapi/validator-arktype
Extend Kaapi:
import { Kaapi } from '@kaapi/kaapi';
import { validatorArk } from '@kaapi/validator-arktype';
const app = new Kaapi({ /* ... */ });
const start = async () => {
await app.extend(validatorArk);
};
start();
Now the route:
import { withSchema } from '@kaapi/validator-arktype';
import { type } from 'arktype';
const route = withSchema({
params: type({
name: '3 <= string <= 10'
})
}).route({
method: 'GET',
path: '/hello/{name}',
handler: function (request, h) {
return `Hello ${request.params.name}!`;
}
});
What actually matters here
Regardless of the validator:
- Routes are defined independently
- Each route picks the validator it wants
- Registration stays dead simple:
app.route(route);
Joi works out of the box.
Zod, Valibot, and ArkType plug in via withSchema.
Same app. Same API. Different tools.
So… which validator should you use?
Quick recap:
- Minimal code? → Joi or ArkType
- Strong type inference without generics? → Zod or Valibot
- Human-readable schemas? → ArkType
- Great auto-docs? → Zod, Valibot, or Joi
Kaapi doesn’t force a choice.
It gives you the whole toolbox. Pick the screwdriver you like using.
Source code
Want the full example?
👉 github.com/shygyver/kaapi-monorepo-playground
The example lives under validation-app.
Check the README for run instructions.
More Kaapi articles are coming.
This one was just about keeping validation where it belongs.
📦 Get started now
npm install @kaapi/kaapi
🔗 Learn more: https://github.com/demingongo/kaapi/wiki
Top comments (0)