DEV Community

Cover image for Building Modern Backends with Kaapi: Request validation Part 2
ShyGyver
ShyGyver

Posted on

Building Modern Backends with Kaapi: Request validation Part 2

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}!`;
    }
  });
Enter fullscreen mode Exit fullscreen mode

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}!`;
  }
};
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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}!`;
  }
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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}!`;
  }
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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}!`;
  }
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

🔗 Learn more: https://github.com/demingongo/kaapi/wiki


Top comments (0)