All Articles

Customising & Formatting Joi Errors For Improved Readability


Machine

Problem

Summary

You’re using Joi for validation of a non-trivial schema; you’re seeing lots of errors - perhaps you’re using { abortEarly: false } to build a schema and test it against a legacy API - and you’re struggling to read through them all easily.

Example

// joiOptions = { abortEarly: false }
ValidationError: child "myObj" fails because ["myObj" at position 0 fails because [child "myKey" fails because ["mySubKey" is not allowed], "myKey" is not allowed, "myKey" is not allowed], "myObj" at position 1 fails because [child "myKey" fails because ["mySubKey" is not allowed], "myKey" is not allowed, "myKey" is not allowed], "myObj" at position 2 fails because [child "myKey" fails because ["mySubKey" is not allowed], "myKey" is not allowed, "myKey" is not allowed], "myObj" at position 3 fails because [child "myKey" fails because ["mySubKey" is not allowed], "myKey" is not allowed, "myKey" is not allowed], "myObj" at position 4 fails because [child "myKey" fails because ["mySubKey" is not allowed], "myKey" is not allowed, "myKey" is not allowed], "myObj" at position 5 fails because [child "myKey" fails because ["mySubKey" is not allowed], "myKey" is not allowed, "myKey" is not allowed]]

Solution

Summary

Use the synchronous version of the joi.validate function and check the error key on the response. If it exists, process and format the errors according to your needs.

Joi’s error response contains a details key which lists out the details of each ValidationError. Looping through we are able to format errors however we want.

I made a personal choice to format the errors something like this (see bottom of post for example responses):

interface ErrorResponse {
  [key: string]: { // Error message
    currentValue: string; // Value of validation failure
    currentType: string; // Type of validation failure
  }
}

I also created an optional parameter for array indexes so I could de-duplicate the same type and value error when it occurs multiple times:

interface ErrorOptions {
  showErrorIndexes?: boolean;
}

To avoid having to repeat myself during testing I created a wrapper function for joi.validate itself and used this whilst trying to ensure backwards compatability for legacy APIs. Using this in conjunction with Mocha as a test runner proved really useful, calling each API and validating the response.

TL;DR: I created an npm package for my solution here

Implementation

import joi from 'joi';

/**
 * The returned error interface
 */
interface ErrorResponse {
  [key: string]: {
    currentValue: string;
    currentType: string;
  }
}

/**
 * Options that can be passed to joiErrorFormatter
 */
interface ErrorOptions {
  showErrorIndexes?: boolean;
}

/**
 * Custom error formatter for joi
 */
export function joiErrorFormatter(
  { details }: joi.ValidationError,
  { showErrorIndexes }: ErrorOptions = {}
): void {

  const errorResponseObj: ErrorResponse = {};

  // Loop through each error
  details.forEach(e => {
    let path: string;

    // Optionally choose whether to show the exact index where a validation error occured
    // Not showing array indexes de-duplicates cases where same validation error is occurring multiple times in an array
    if (showErrorIndexes) {
      path = `${e.path.map(p => (typeof p === 'number' ? `[${p}]` : p)).join('.')}`;
    } else {
      path = `${e.path.filter(p => typeof p !== 'number').join('.')}`;
    }

    const currentValue = e.context ? e.context.value : undefined;
    const errorMessage = `${e.message.replace(/\".*\"/g, path)}`;

    const errorDetails = {
      currentValue: currentValue !== undefined ? currentValue : 'unable to determine current value',
      currentType: currentValue !== undefined ? typeof currentValue : 'unable to determine current value type'
    };

    errorResponseObj[errorMessage] = errorDetails;
  });

  throw Error(JSON.stringify(errorResponseObj, null, 4));
}

/**
 * Optional wrapper for joi validate
 */
export function joiValidateWrapper<T>(
  data: T,
  schema: joi.SchemaLike,
  validationOptions: joi.ValidationOptions,
  errorOptions?: ErrorOptions
): joi.ValidationResult<T> {

  const res = joi.validate(data, schema, validationOptions);

  if (res.error) {
    joiErrorFormatter(res.error, errorOptions);
  }

  return res;
}

Usage

/**
 * Use the error formatter directly when there is an error
 */
const res = joi.validate(data, schema, validationOptions);

if (res.error) {
  joiErrorFormatter(res.error, errorOptions);
}

// ---- OPTIONAL WRAPPER

/**
 * Call the wrapper as you would joi.validate with an extra argument for the error formatter
 */
const res = joiValidateWrapper(data, joiSchema, { abortEarly: false }, { showErrorIndexes: false })

For comparision, here are the validation responses from joi 14.3.1 (as above) and the two possible responses of the custom wrapper:

joi 14.3.1

// joiOptions = { abortEarly: false }
ValidationError: child "myObj" fails because ["myObj" at position 0 fails because [child "myKey" fails because ["mySubKey" is not allowed], "myKey2" is not allowed, "myKey3" is not allowed], "myObj" at position 1 fails because [child "myKey" fails because ["mySubKey" is not allowed], "myKey2" is not allowed, "myKey3" is not allowed], "myObj" at position 2 fails because [child "myKey" fails because ["mySubKey" is not allowed], "myKey2" is not allowed, "myKey3" is not allowed], "myObj" at position 3 fails because [child "myKey" fails because ["mySubKey" is not allowed], "myKey2" is not allowed, "myKey3" is not allowed], "myObj" at position 4 fails because [child "myKey" fails because ["mySubKey" is not allowed], "myKey2" is not allowed, "myKey3" is not allowed], "myObj" at position 5 fails because [child "myKey" fails because ["mySubKey" is not allowed], "myKey2" is not allowed, "myKey3" is not allowed]]

joiErrorFormatter { showErrorIndexes: false }

// joiOptions = { abortEarly: false }, errorOptions = { showErrorIndexes: false }
Error: {
    "myObj.myKey.mySubKey is not allowed": {
        "currentValue": true,
        "currentType": "boolean"
    },
    "myObj.myKey2 is not allowed": {
        "currentValue": 5,
        "currentType": "number"
    },
    "myObj.myKey3 is not allowed": {
        "currentValue": null,
        "currentType": "object" // This is the node response for typeof null
    }
}

joiErrorFormatter { showErrorIndexes: true }

// joiOptions = { abortEarly: false }, errorOptions = { showErrorIndexes: true }
Error: {
    "myObj.[0].myKey.mySubKey is not allowed": {
        "currentValue": true,
        "currentType": "boolean"
    },
    "myObj.[0].myKey2 is not allowed": {
        "currentValue": 5,
        "currentType": "number"
    },
    "myObj.[0].myKey3 is not allowed": {
        "currentValue": null,
        "currentType": "object"
    },
    "myObj.[1].myKey.mySubKey is not allowed": {
        "currentValue": true,
        "currentType": "boolean"
    },
    "myObj.[1].myKey2 is not allowed": {
        "currentValue": 5,
        "currentType": "number"
    },
    "myObj.[1].myKey3 is not allowed": {
        "currentValue": null,
        "currentType": "object"
    },
    "myObj.[2].myKey.mySubKey is not allowed": {
        "currentValue": true,
        "currentType": "boolean"
    },
    "myObj.[2].myKey2 is not allowed": {
        "currentValue": 5,
        "currentType": "number"
    },
    "myObj.[2].myKey3 is not allowed": {
        "currentValue": null,
        "currentType": "object"
    },
    "myObj.[3].myKey.mySubKey is not allowed": {
        "currentValue": true,
        "currentType": "boolean"
    },
    "myObj.[3].myKey2 is not allowed": {
        "currentValue": 5,
        "currentType": "number"
    },
    "myObj.[3].myKey3 is not allowed": {
        "currentValue": null,
        "currentType": "object"
    },
    "myObj.[4].myKey.mySubKey is not allowed": {
        "currentValue": true,
        "currentType": "boolean"
    },
    "myObj.[4].myKey2 is not allowed": {
        "currentValue": 5,
        "currentType": "number"
    },
    "myObj.[4].myKey3 is not allowed": {
        "currentValue": null,
        "currentType": "object"
    },
    "myObj.[5].myKey.mySubKey is not allowed": {
        "currentValue": true,
        "currentType": "boolean"
    },
    "myObj.[5].myKey2 is not allowed": {
        "currentValue": 5,
        "currentType": "number"
    },
    "myObj.[5].myKey3 is not allowed": {
        "currentValue": null,
        "currentType": "object"
    },

Problem Solved

If you have any questions you’d like to ask me about this post, feel free to reach me on Twitter or Github.