Better data validation with Guard Clauses

Let's use Defensive Programming to make sure that invalid data never makes it again!

ยท

16 min read

Have you ever heard of Defensive Programming and the Fail-Fast system design? These are guidelines which tell us to always validate a method's input. Something like this:

function sayHi(firstName, lastName) {
  if (!firstName?.length)
    throw new Error("The first name is required.");

  if (!lastName?.length)
    throw new Error("The last name is required.");

  return `Hi, ${firstName} ${lastName}!`;
}

The code that validates the input is called a Guard Clause. It is there to protect your code from invalid data, also from bugs and unexpected behaviors.

But of course we can do better than that, because writing this type of code is tedious and is also a bad practice, since it is repeated code which violates the DRY principle (Don't Repeat Yourself).

A better approach is to write a helper class with all the guard clauses you'll be needing in your project. Let's start with this:

export class Guard {
  constructor(public value: any, public name: string) { }

  null(message?: string): Guard {
    if (this.value === null || this.value === undefined) {
      throw new Error(message || `{this.name} must not be null nor undefined.`);
    }
    return this;
  }

  nullOrEmpty(message?: string): Guard {
    if (this.value === null || this.value === undefined || this.length === 0) {
      throw new Error(message || `{this.name} is required.`);
    }
    return this;
  }

  // Add more guard clauses to validate numbers, dates, arrays...
  // We'll get to that later.
}

export function guard(public value: any, public name: string) {
  return new Guard(value, name);
}

And now we can use it like this:

function sayHi(firstName, lastName) {
  guard(firstName, "firstName").nullOrEmpty();
  guard(lastName, "lastName").nullOrEmpty();
  return `Hi, ${firstName} ${lastName}!`;
}

When to use Guard Clauses

An even better question is WHERE to use them.

This is not something you wanna use to validate end-user input.

See the Errors we're throwing in our guard clauses? An Error is an exceptional case, meaning you're not expecting invalid data to get there in the first place, and the guard clause is a last resource.

The best place for a guard clause is actually in your domain model classes, but, if you're building an API, for example, you'll want put a JSON Schema validator such as AJV and return a 400 Bad Request well before you ever get the chance to look at invalid data.

But why repeat the validation? I mean, I just told you to validate your requests with AJV and then revalidate everything with guard clauses, and I also mentioned to DRY principle... Am I crazy? ๐Ÿคช

An API is just one entry point for your models, like an User Interface, but a model is often reused in other places, such as background processes, internal services, different workflows... you name it.

And unless you work alone, the other engineers in your team are also "entry points" for your models. They are users, and you should write code that does not allow users to make mistakes in the first place.

So, the way I recomend using it is like this:

export class Person {
  private _firstName!: string;
  private _lastName!: string;

  constructor(firstName: string, lastName: string) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  get firstName(): string {
    return this._firstName;
  }
  set firstName(value: string) {
    this._firstName = guard(value, "firstName").nullOrEmpty().value;
  }

  get lastName(): string {
    return this._lastName;
  }
  set lastName(value: string) {
    this._lastName = guard(value, "lastName").nullOrEmpty().value;
  }
}

And this will fail miserably ๐Ÿ’€:

const person = new Person("Phill", null); // ๐Ÿ’ฅ๐Ÿ’ฅ๐Ÿ’ฅ

This approach follows the Object Oriented design and enforces consistency. Read this to learn more:

A whole lot of guard clauses

UPDATE: A newer and better version of the guard clauses is now available as an NPM package:


Here, take this gift ๐Ÿค—

  • changeTo: Changes the guarded value to the specified value.

  • dateTime: Ensures that the value, if present, is a valid ISO datetime string.

  • default: Provides a default value, in case the original is null or undefined.

  • email: Ensures that the value, if present, is a valid email address.

  • ensure: If a value is present, 'ensure' runs it through the provided function and expects it to return true in order to be considered valid.

  • enum: Ensures that the value, if present, is a valid key of the provided enumObject.

  • hostname: Ensures that the value, if present, is a valid hostname (domain name).

  • in: Ensures that the value, if present, matches one of the possible values.

  • is: Ensures that the value, if present, exactly equals (===) the specified value.

  • isNot: Ensures that the value, if present, does NOT exactly equals (!==) the specified value.

  • items: Steps into the items of the input value, when it is an array or object.

  • length: Ensures that the value, if present and is a string or array, has length lesser than or equal to max, and greater than or equal to min.

  • lowerCase: If the guarded value is a string, makes it lower case.

  • max: Ensures that the value, if present and is a number, is lesser than or equal to maxValue.

  • maxLength: Ensures that the value, if present and is a string or array, has length lesser than or equal to length.

  • min: Ensures that the value, if present and is a number, is greater than or equal to minValue.

  • minLength: Ensures that the value, if present and is a string or array, has length greater than or equal to length.

  • null: Ensures that the value has value, that is, it is not null nor undefined.

  • nullOrEmpty: Ensures that the value has a value (not null nor undefined) and that it is not empty, which is a check that works against objects (must have at least one key), arrays (at least one element) and strings (length greater then 0).

  • nullOrWhitespace: Ensures that the value is a string with at least one character that is not a whitespace.

  • pattern: Ensures that the value, if present, matches the provided regex pattern.

  • patterns: Ensures that the value, if present, matches at least one of the provided patterns.

  • range: Ensures that the value, if present and is a number, is in the specified range.

  • upperCase: If the guarded value is a string, makes it upper case.

  • url: Ensures that the value, if present, is a valid URL.

  • transform: If the guarded value is not null nor undefined, runs it through the provided transform function.

  • trim: If the guarded value is a string, trims it.

import { GuardError } from "./GuardError";

const BASE_DATE = new Date(0).valueOf();

export class Guard {
  constructor(public value: any, public parameterName = "value") {}

  /**
   * Changes the guarded value to the specified value.
   * @param value The new value.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  changeTo(value: any): Guard {
    this.value = value;
    return this;
  }

  /**
   * Ensures that the value, if present, is a valid ISO datetime string.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  dateTime(message?: string): Guard {
    if (this.hasValue()) {
      message = message || `${this.parameterName} must be a valid ISO datetime string.`;
      if (typeof this.value !== "string") throw new GuardError(this.parameterName, message);
      const parsed = Date.parse(this.value);
      if (isNaN(parsed) || parsed < BASE_DATE) throw new GuardError(this.parameterName, message);
    }
    return this;
  }

  /**
   * Provides a default value, in case the original is null or undefined.
   * @param defaultValue The default value to be used in case the original is missing.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  default(defaultValue: any): Guard {
    if (this.value === null || this.value === undefined) this.value = defaultValue;
    return this;
  }

  /**
   * Ensures that the value, if present, is a valid email address.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  email(message?: string): Guard {
    return this.pattern(
      /^\w+@[a-zA-Z_]+?\.[a-zA-Z]{2,3}$/,
      message || `${this.parameterName} must be a valid email.`
    );
  }

  /**
   * If a value is present, 'ensure' runs it through the provided function and expects it to return true in order to be considered valid.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  ensure(isValid: (value: any) => boolean, message?: string): Guard {
    if (this.hasValue() && isValid(this.value) !== true)
      throw new GuardError(
        this.parameterName,
        message || `${this.parameterName} '${this.value}' is not valid.`
      );
    return this;
  }

  /**
   * Ensures that the value, if present, is a valid key of the provided @argument enumObject.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  enum(enumObject: any, message?: string): Guard {
    if (this.hasValue() && !Object.values(enumObject).includes(this.value))
      throw new GuardError(
        this.parameterName,
        message || `${this.parameterName} '${this.value}' is not a valid option.`
      );
    return this;
  }

  /**
   * Ensures that the value, if present, is a valid hostname (domain name).
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  hostname(message?: string): Guard {
    return this.pattern(
      /^(?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?)*\.?$/,
      message || `${this.parameterName} must be a valid hostname (domain name).`
    );
  }

  /**
   * Ensures that the value, if present, matches one of the possible values.
   * @param possibleValues An array of the possible values.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  in(possibleValues: any[], message?: string): Guard {
    if (this.hasValue() && !possibleValues.includes(this.value)) {
      throw new GuardError(
        this.parameterName,
        message ||
          `${this.parameterName} is not one of the expected values: ${possibleValues.join(", ")}.`
      );
    }
    return this;
  }

  /**
   * Ensures that the value, if present, exactly equals (===) the specified value.
   * @param value The value to compare the guarded value against.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  is(value: any, message?: string): Guard {
    if (this.hasValue() && this.value !== value)
      throw new GuardError(
        this.parameterName,
        message || `${this.parameterName} must be equal to '${value}'.`
      );
    return this;
  }

  /**
   * Ensures that the value, if present, does NOT exactly equals (!==) the specified value.
   * @param value The value to compare the guarded value against.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  isNot(value: any, message?: string): Guard {
    if (this.hasValue() && this.value === value)
      throw new GuardError(
        this.parameterName,
        message || `${this.parameterName} must NOT be equal to '${value}'.`
      );
    return this;
  }

  /**
   * Steps into the items of the input value, when it is an array or object.
   * @param itemValidator A function that will validate each item. This function can expect to receive a @see Guard object containing the current value.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  items(itemValidator: (guard: Guard) => Guard): Guard {
    if (typeof this.value === "object") {
      for (const key of Object.keys(this.value)) {
        itemValidator(new Guard(this.value[key]));
      }
    } else if (Array.isArray(this.value)) {
      for (const item of this.value) {
        itemValidator(new Guard(item));
      }
    }
    return this;
  }

  /**
   * Ensures that the value, if present and is a string or array,
   * has length lesser than or equal to @argument max,
   * and greater than or equal to @argument min.
   * @param length The min length allowed for the guarded value.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  length(min: number, max: number, message?: string) {
    return this.minLength(min, message).maxLength(max, message);
  }

  /**
   * If the guarded value is a string, makes it lower case.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  lowerCase(): Guard {
    if (typeof this.value === "string") this.value = this.value.toLowerCase();
    return this;
  }

  /**
   * Ensures that the value, if present and is a number, is lesser than or equal to @argument maxValue.
   * @param maxValue The max number value allowed for the guarded value.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  max(maxValue: number, message?: string) {
    if (typeof this.value === "number" && this.value > maxValue)
      throw new GuardError(
        this.parameterName,
        message || `${this.parameterName} cannot be greater than ${maxValue}.`
      );
    return this;
  }

  /**
   * Ensures that the value, if present and is a string or array, has length lesser than or equal to @argument length.
   * @param length The min length allowed for the guarded value.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  maxLength(length: number, message?: string) {
    if ((typeof this.value === "string" || Array.isArray(this.value)) && this.value.length > length)
      throw new GuardError(
        this.parameterName,
        message || `${this.parameterName} must have a maximum length of ${length}.`
      );
    return this;
  }

  /**
   * Ensures that the value, if present and is a number, is greater than or equal to @argument minValue.
   * @param minValue The min number value allowed for the guarded value.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  min(minValue: number, message?: string) {
    if (typeof this.value === "number" && this.value < minValue)
      throw new GuardError(
        this.parameterName,
        message || `${this.parameterName} cannot be lesser than ${minValue}.`
      );
    return this;
  }

  /**
   * Ensures that the value, if present and is a string or array, has length greater than or equal to @argument length.
   * @param length The min length allowed for the guarded value.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  minLength(length: number, message?: string) {
    if ((typeof this.value === "string" || Array.isArray(this.value)) && this.value.length < length)
      throw new GuardError(
        this.parameterName,
        message || `${this.parameterName} must have a minimum length of ${length}.`
      );
    return this;
  }

  /**
   * Ensures that the value has value, that is, it is not null nor undefined.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  null(message?: string): Guard {
    if (!this.hasValue())
      throw new GuardError(
        this.parameterName,
        message || `${this.parameterName} must not be null.`
      );
    return this;
  }

  /**
   * Ensures that the value has a value (not null nor undefined) and that it is not empty, which is a check
   * that works against objects (must have at least one key), arrays (at least one element) and strings
   * (length greater then 0).
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  nullOrEmpty(message?: string): Guard {
    if (
      !this.hasValue() ||
      (typeof this.value === "object" && Object.keys(this.value).length === 0) ||
      (typeof this.value === "string" && this.value.length === 0) ||
      (Array.isArray(this.value) && this.value.length === 0)
    )
      throw new GuardError(
        this.parameterName,
        message || `${this.parameterName} must not be null nor empty.`
      );
    return this;
  }

  /**
   * Ensures that the value is a string with at least one character that is not a whitespace.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  nullOrWhitespace(message?: string): Guard {
    if (typeof this.value !== "string" || this.value.trim().length === 0)
      throw new GuardError(
        this.parameterName,
        message || `${this.parameterName} must not be empty.`
      );
    return this;
  }

  /**
   * Ensures that the value, if present, matches the provided regex pattern.
   * @param regex The regex pattern.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  pattern(regex: RegExp, message?: string): Guard {
    if (this.hasValue() && !regex.test(this.value))
      throw new GuardError(
        this.parameterName,
        message || `${this.parameterName} must match the pattern: ${regex}`
      );
    return this;
  }

  /**
   * Ensures that the value, if present, matches at least one of the provided patterns.
   * @param regexes An array of regexes.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  patterns(regexes: RegExp[], message?: string): Guard {
    if (this.hasValue() && !regexes.some((regex) => regex.test(this.value)))
      throw new GuardError(
        this.parameterName,
        message ||
          `${this.parameterName} must match at least one of the patterns: ${regexes.join(" OR ")}`
      );
    return this;
  }

  /**
   * Ensures that the value, if present and is a number, is in the specified range.
   * @param maxValue The max number value allowed for the guarded value.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  range(minValue: number, maxValue: number, message?: string) {
    if (typeof this.value === "number" && (this.value < minValue || this.value > maxValue))
      throw new GuardError(
        this.parameterName,
        message || `${this.parameterName} must be a value from ${minValue} to ${maxValue}.`
      );
    return this;
  }

  /**
   * If the guarded value is a string, makes it upper case.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  upperCase(): Guard {
    if (typeof this.value === "string") this.value = this.value.toUpperCase();
    return this;
  }

  /**
   * Ensures that the value, if present, is a valid URL.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  url(message?: string): Guard {
    return this.pattern(
      /^(ftp|http|https):\/\/[^ "]+$/,
      message || `${this.parameterName} must be a valid URL.`
    );
  }

  /**
   * Ensures that the value, if present, is a valid timezone name according to the TZ database.
   * @param message Optional message. If not provided, a default message will be used.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  timezone(message?: string): Guard {
    return this.enum(
      Timezone,
      message ||
        `${this.parameterName} '${this.value}' is not a valid timezone name according to the TZ database.`
    );
  }

  /**
   * If the guarded value is not null nor undefined, runs it through the provided transform function.
   * @param fn The transform function, which receives the guarded value and is expected to return a new one.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  transform(fn: (value: any) => any): Guard {
    if (this.hasValue()) this.value = fn(this.value);
    return this;
  }

  /**
   * If the guarded value is a string, trims it.
   * @returns A @see Guard object, for following up with other guard methods or obtaining the input value.
   */
  trim(): Guard {
    if (typeof this.value === "string") this.value = this.value.trim();
    return this;
  }

  private hasValue(): boolean {
    return this.value !== null && this.value !== undefined;
  }
}

export function guard(value: any, parameterName?: string) {
  return new Guard(value, parameterName);
}

Disclaimer: I crossed a line here and added transformation functions such as trim and lowerCase, not just pure guard clauses. I'm aware that it violates the SRP (Single Responsibility Principle), but just a little ๐Ÿ™„ and it ultimately helps me have more consistent style for my data, so what the hell.

Take care, people!

ย