DEV Community

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

Posted on

Building Modern Backends with Kaapi: Request validation

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

Nothing surprising here… except Kaapi can automatically generate documentation from your validation.

See: documentation generation article.

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

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

Hover over request.params.name in VSCode to see the magic of type inference.
No generics, no sadness (even without Joi).

VSCode TypeScript Language Service type inference


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

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

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

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

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

Definitions:

  • n: total objects
  • r: objects chosen at once

Rules:

  • r must be an integer > 0
  • r ≤ n

Bonus rules (because constraints are fun):

  • n ∈ [1, 50]
  • r ∈ {1, 2, 3}
  • r defaults 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)
    })
  );
Enter fullscreen mode Exit fullscreen mode

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

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

ArkType gets us most of the way there, but its auto-documentation leaves a few holes. That’s why we stepped in with RequestBodyDocsModifier to 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)
  })
);
Enter fullscreen mode Exit fullscreen mode

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

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


Top comments (0)