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?
Before we let any data wander freely into our backend (with muddy boots and questionable intentions), it’s usually a good idea to validate it.
Kaapi is built on top of Hapi, which already comes with great tools for this, thanks to Joi. If you want the full lecture, Hapi’s tutorial explains everything in glorious detail.
Here’s a tiny example from their documentation, just to remind ourselves how things look with Joi:
app.route<{ Params: { name: string } }>({
method: 'GET',
path: '/hello/{name}',
handler: function (request, h) {
return `Hello ${request.params.name}!`;
},
options: {
validate: {
params: Joi.object({
name: Joi.string().min(3).max(10)
})
}
}
});
Nothing surprising here… except Kaapi can automatically generate documentation from your validation.
But Kaapi targets TypeScript, so naturally we want life to be as type-safe and boilerplate-free as possible.
So why are we still writing generic type arguments?
Writing <{ Params: { name: string } }> works, but feels unnecessary when the validator already knows the type.
Zod, Valibot and ArkType provide type inference. Kaapi happily consumes that and eliminate boilerplate for you.
Let’s take a tour through all three.
Integrating Validators
To use anything other than Joi, you install the appropriate plugin and extend your Kaapi instance.
Validate with Zod
Install:
zod@kaapi/validator-zod
Extend your app:
import { Kaapi } from '@kaapi/kaapi';
import { validatorZod } from '@kaapi/validator-zod';
const app = new Kaapi({ /* ... */ });
const start = async () => {
await app.extend(validatorZod);
};
start();
And now you can do:
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}!`;
}
});
Hover over request.params.name in VSCode to see the magic of type inference.
No generics, no sadness (even without Joi).
Validate with Valibot
Install:
valibot@kaapi/validator-valibot
Extend:
import { Kaapi } from '@kaapi/kaapi';
import { validatorValibot } from '@kaapi/validator-valibot';
const app = new Kaapi({ /* ... */ });
const start = async () => {
await app.extend(validatorValibot);
};
start();
Use it:
import * as v from 'valibot';
app
.base()
.valibot({
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}!`;
}
});
Valibot is extremely fast and super modular.
It’s like Lego bricks, if Lego bricks did runtime type checking.
Validate with ArkType
Install:
arktype@kaapi/validator-arktype
Extend:
import { Kaapi } from '@kaapi/kaapi';
import { validatorArk } from '@kaapi/validator-arktype';
const app = new Kaapi({ /* ... */ });
const start = async () => {
await app.extend(validatorArk);
};
start();
Use:
import { type } from 'arktype';
app
.base()
.ark({
params: type({
name: '3 <= string <= 10'
})
})
.route({
method: 'GET',
path: '/hello/{name}',
handler: function (request, h) {
return `Hello ${request.params.name}!`;
}
});
ArkType is almost… poetic.
Where else can you write '3 <= string <= 10' and have TypeScript say "Yep, that checks out"?
A Real Example: Binomial Coefficients!
We’ve all written “Hello World” too many times, so let’s compute something actually useful:
the binomial coefficient, a.k.a. n choose r.
Here’s the function:
function binomialCoefficient(n: number, r: number) {
if (r < 0 || r > n) return 0;
let result = 1;
for (let i = 1; i <= r; i++) {
result = result * (n - i + 1) / i;
}
return result;
}
Definitions:
-
n: total objects -
r: objects chosen at once
Rules:
-
rmust be an integer > 0 r ≤ n
Bonus rules (because constraints are fun):
n ∈ [1, 50]r ∈ {1, 2, 3}-
rdefaults to 3 - Input may be a string or number
- Validators should convert strings → numbers
Below we implement this in Zod, Valibot, ArkType, and finally Joi.
(Yes, we’re doing all four. Because we can.)
Zod Version
import { z } from 'zod';
// payload schema
const combinationPayloadSchema = z
.object({
n: z
.preprocess((x) => Number(x), z.int().positive().min(1).max(50))
.meta({
description: 'The total number of objects in the set',
examples: [5],
}),
r: z
.preprocess(
(x) => Number(x),
z.enum({
one: 1,
two: 2,
three: 3,
})
)
.optional()
.default(3)
.meta({
description: 'The number of objects chosen at once',
examples: [3],
}),
})
.refine(
(schema) => schema.n >= schema.r,
{ error: 'make sure that n ≥ r' }
)
.meta({
description: 'combination nCr inputs',
ref: '#/components/schemas/CombinationInputsWithZod',
});
// route
app.base()
.zod({ payload: combinationPayloadSchema })
.route(
{
method: 'POST',
path: '/zod/combination',
options: {
description: 'Calculate the combination of n and r.',
tags: ['zod'],
payload: {
allow: ['application/json', 'application/x-www-form-urlencoded'],
},
},
},
({ payload: { n, r } }) => ({
inputs: { n, r },
coefficient: binomialCoefficient(n, r)
})
);
Valibot Version
import * as v from 'valibot';
// payload schema
const combinationPayloadSchema = v.pipe(
v.object({
n: v.pipe(
v.union([
v.number(),
v.pipe(v.string(), v.transform((input) => Number(input))),
]),
v.integer(),
v.minValue(1),
v.maxValue(50),
v.description('The total number of objects in the set'),
v.metadata({ examples: [5] })
),
r: v.optional(
v.pipe(
v.union([
v.number(),
v.pipe(v.string(), v.transform((input) => Number(input))),
]),
v.integer(),
v.union([v.literal(1), v.literal(2), v.literal(3)]),
v.description('The number of objects chosen at once'),
v.metadata({ examples: [3] })
),
3
),
}),
v.check((input) => input.n >= input.r, 'make sure that n ≥ r'),
v.description('combination nCr inputs'),
v.metadata({
ref: '#/components/schemas/CombinationInputsWithValibot',
})
);
// route
app.base()
.valibot({ payload: combinationPayloadSchema })
.route(
{
method: 'POST',
path: '/valibot/combination',
options: {
description: 'Calculate the combination of n and r.',
tags: ['valibot'],
payload: {
allow: ['application/json', 'application/x-www-form-urlencoded'],
},
},
},
({ payload: { n, r } }) => ({
inputs: { n, r },
coefficient: binomialCoefficient(n, r)
})
);
ArkType Version
import { RequestBodyDocsModifier } from '@kaapi/kaapi';
import { type } from 'arktype';
// payload schema
const combinationPayloadSchema = type([
{
n: type([
'number.integer | string',
'@',
{ description: 'The total number of objects in the set', expected: 'an integer', examples: [5], format: 'integer' },
])
.pipe((v) => Number(v))
.to('1 <= number.integer <= 50'),
r: type([
'1 | 2 | 3 | string',
'@',
{
description: 'The number of objects chosen at once',
expected: '1, 2 or 3',
examples: [3],
format: 'integer',
},
])
.pipe((v) => (typeof v === 'string' ? (v ? Number(v) : NaN) : v))
.to('1 | 2 | 3')
.default(3),
},
'@',
'combination nCr inputs',
]).pipe((payload) => {
return type({
n: type(`number >= ${payload.r}`, '@', '≥ r'),
r: 'number',
})(payload);
});
// route
app.base()
.ark({
payload: combinationPayloadSchema,
})
.route(
{
method: 'POST',
path: '/ark/combination',
options: {
description: 'Calculate the combination of n and r.',
tags: ['ark'],
payload: {
allow: ['application/json', 'application/x-www-form-urlencoded'],
},
plugins: {
kaapi: {
docs: {
modifiers: () => ({
requestBody: new RequestBodyDocsModifier()
.addMediaType('application/json', {
schema: {
properties: {
n: {
minimum: 1,
maximum: 50,
},
},
},
})
.addMediaType('application/x-www-form-urlencoded', {
schema: {
properties: {
n: {
minimum: 1,
maximum: 50,
},
},
},
}),
}),
},
},
},
},
},
({ payload: { n, r } }) => ({ inputs: { n, r }, coefficient: binomialCoefficient(n, r) })
);
ArkType gets us most of the way there, but its auto-documentation leaves a few holes. That’s why we stepped in with
RequestBodyDocsModifierto round things out.
Joi Version (for comparison)
import Joi from 'joi';
// payload schema
const combinationPayloadSchema = Joi.object({
n: Joi.number()
.description('The total number of objects in the set')
.integer()
.min(1)
.max(50)
.example(5)
.when('r', {
is: Joi.number(),
then: Joi.number().min(Joi.ref('r')),
})
.required(),
r: Joi.number()
.description('The number of objects chosen at once')
.integer()
.valid(1, 2, 3)
.default(3)
.example(3)
.optional(),
})
.meta({ ref: '#/components/schemas/CombinationInputsWithJoi' })
.description('combination nCr inputs')
.required();
// route
app.route<{ Payload: { n: number; r: 1 | 2 | 3 } }>(
{
method: 'POST',
path: '/joi/combination',
options: {
description: 'Calculate the combination of n and r.',
tags: ['joi'],
payload: {
allow: ['application/json', 'application/x-www-form-urlencoded'],
},
validate: {
payload: combinationPayloadSchema,
},
},
},
({ payload: { n, r } }) => ({
inputs: { n, r },
coefficient: binomialCoefficient(n, r)
})
);
Joi still requires the explicit generic type
<{ Payload: … }>, but the schema itself is wonderfully compact.
So… Which Validator Should You Use?
Honestly?
Any of them.
Kaapi doesn’t play favourites. You can mix and match all four in the same project.
Here’s the vibe check:
| Validator | Pros | Cons |
|---|---|---|
| Zod | Great DX, powerful transforms, strong TS inference | Verbose |
| Valibot | Very fast, very modular | Piping can get long |
| ArkType | Most human-readable ("3 <= string <= 10"), minimal syntax |
Weaker doc generation, sometimes needs manual modifiers for complex schemas |
| Joi | Built-in with Hapi/Kaapi, concise | Needs explicit TS generics |
Choose based on:
- Minimal code? → Joi or ArkType
- Strong type inference without generics? → Zod or Valibot
- Human-readable schemas? → ArkType
- Excellent auto-docs? → Zod / Valibot / Joi
Basically:
Kaapi gives you the whole toolbox. Pick the screwdriver that makes you happy.
Source Code
Curious to see the full app, judge the architecture, or spot a typo?
👉 github.com/shygyver/kaapi-monorepo-playground
The example lives under validation-app.
See the README for instructions on running it.
More Kaapi articles are coming! It has plenty more tricks up its sleeve.
📦 Get started now
npm install @kaapi/kaapi
🔗 Learn more: https://github.com/demingongo/kaapi/wiki

Top comments (0)