haroldadmin

Joi for Data Transformations

Nov 21, 2021

Hero Image

The Node.js community has access to many data validation libraries like Ajv, Yup, class-validator, validate.js, and more. One library that stands out to me is Joi.

Joi is popular, battle tested, and very powerful, but it's often mistaken to be "just" a data validation library even though it can also transform data. This ability is really useful when building REST APIs, as I'll demonstrate using a combination of Nest.js and Joi.

Consider the following Nest.js Pipe that validates an incoming request body using a Joi schema:

import { Schema } from "joi";
import { PipeTransform, BadRequestException } from "@nestjs/common";

class SchemaValidator implements PipeTransform {
  constructor(readonly schema: Schema) {}

  transform(incoming: unknown): unknown {
    const { error } = this.schema.validate(incoming);
    if (error) {
      throw new BadRequestException(error.message);
    }

    return incoming;
  }
}

// Convenient way to apply the SchemaValidator
function useSchema(schema: Schema): SchemaValidator {
  return new SchemaValidator(schema);
}

It runs the incoming value through the given schema to validate it. It then returns a "400 Bad Request" response if the validation is unsuccessful, or returns the value as-is otherwise. You can apply it as any other pipe:

@Controller()
class UserController {
  @Get("/:email/picture")
  getProfilePicture(
    @Param("email", useSchema(Joi.string().email().required()))
    email: string
  ): string {
    // ...
  }
}

A common practice when working with emails is to lowercase them before usage. The simplest way to do this would be to call email.toLowerCase() in the controller method, but you could also leverage Joi's capabilities for this.

First, apply the lowercase transformation to the schema:

@Get("/:email/picture)
getProfilePicture(
  @Param("email", withSchema(Joi.string().email().lowercase().required()))
    email: string
)

Then adjust the transform method of SchemaValidator pipe to return Joi's validated value instead of the original value:

transform(incoming: unknown): unknown {
  const { error, value } = this.schema.validate(incoming);
    if (error) {
        throw new BadRequestException(error.message);
    }

  return value;
}

This ensures that the value returned from this pipe will be lowercase as long as the convert option is enabled (it's enabled by default). You can use any of the other available transformations like default(), sort(), binary() when needed, and you could also apply custom transformations using your own functions.

It's a best practice to normalize emails before usage. foo@gmail.com and foo+bar@gmail.com are the same email addresses from Gmail's perspective. Similarly, foobar@gmail.com and foo.bar@gmail.com point to the same email. It's therefore important to normalize email variants before use through a package like normalize-email.

First, write a custom validator for your email schema that uses the normalize-email package:

import { CustomHelpers, ErrorReport } from "joi";
import normalize from "normalize-email";

function normalizeEmail(
  value: string | undefined,
  helpers: CustomHelpers
): string | ErrorReport {
  try {
    const normalized = normalize(value);
    return normalized;
  } catch (error) {
    return helpers.message({ custom: "Failed to normalize email" });
  }
}

Then modify your controller to use it:

@Get("/:email/picture)
getProfilePicture(
  @Param("email", withSchema(Joi.string().email().lowercase().required().custom(normalizeEmail)))
    email: string
)

And now you will always received normalized emails!


If you liked this post and want more content on Joi, let me know with a tweet @haroldadmin!