Skip to the code
cf.

Form validator

Write a class FormValidator that checks a form's values and says what's wrong with each field. The values are a plain object from field name to value, like { email: "ada@example.com", age: 36 }.

new FormValidator(rules) takes an object that maps field names to arrays of rules. A rule is an object { test, message }. test(value, values) gets the field's value and the object of all the values. It returns true when the value is fine and false when it isn't.

message is the text to show when the rule's test fails. It can also be a function. Then it's called only when the test fails, with the same value and values, and it returns the text.

validate(values) checks every field that has rules. It returns an object with an entry for each failing field: the field's name, mapped to its message. Fields that pass aren't in it, so when everything passes, it returns an empty object.

A field's rules run in order and stop at the first one that fails. That rule's message is the field's message, and the rules after it don't run.

A field that has rules but is missing from values is checked with the value undefined. Values for fields that have no rules are ignored.

validateField(name, values) checks one field in the same way and returns its message, or null when it passes. It gets all the values, so rules that compare fields still work. A field with no rules passes.

isValid(values) returns true when every field passes, and false otherwise.

const signup = new FormValidator({
  email: [
    { test: (value) => Boolean(value), message: "Enter your email" },
    { test: (value) => value.includes("@"), message: "Enter a valid email" },
  ],
  password: [
    { test: (value) => value.length >= 8, message: (value) => "Add " + (8 - value.length) + " more characters" },
  ],
  confirm: [
    { test: (value, values) => value === values.password, message: "The passwords don't match" },
  ],
});
 
signup.validate({ email: "", password: "secret", confirm: "secrets" });
// { email: "Enter your email", password: "Add 2 more characters", confirm: "The passwords don't match" }
signup.validateField("email", { email: "ada" });             // "Enter a valid email"
signup.validateField("email", { email: "ada@example.com" }); // null
signup.isValid({ email: "ada@example.com", password: "correct horse", confirm: "correct horse" }); // true

What the tests check

  1. returns an empty object when every field passes
    const form = new FormValidator({
      name: [{ test: (value) => value !== "", message: "Enter your name" }],
      age: [{ test: (value) => value >= 18, message: "You must be 18 or older" }],
    });
    expect(form.validate({ name: "Ada", age: 36 })).toEqual({});
    
  2. maps each failing field to its message
    const form = new FormValidator({
      name: [{ test: (value) => value !== "", message: "Enter your name" }],
      age: [{ test: (value) => value >= 18, message: "You must be 18 or older" }],
      city: [{ test: (value) => value !== "", message: "Enter your city" }],
    });
    expect(form.validate({ name: "", age: 12, city: "Paris" })).toEqual({
      name: "Enter your name",
      age: "You must be 18 or older",
    });
    
  3. uses a field's first failing rule and skips the rules after it
    const later = spy(() => false);
    const form = new FormValidator({
      password: [
        { test: (value) => value.length >= 8, message: "Too short" },
        { test: (value) => /[0-9]/.test(value), message: "Add a digit" },
        { test: later, message: "Never shown" },
      ],
    });
    expect(form.validate({ password: "abc" })).toEqual({ password: "Too short" });
    expect(form.validate({ password: "abcdefgh" })).toEqual({ password: "Add a digit" });
    expect(later).not.toHaveBeenCalled();
    
  4. passes each test the value and all the values
    const test = spy((value, values) => value === values.password);
    const form = new FormValidator({ confirm: [{ test, message: "The passwords don't match" }] });
    const values = { password: "hunter22", confirm: "hunter2" };
    expect(form.validate(values)).toEqual({ confirm: "The passwords don't match" });
    expect(test).toHaveBeenCalledWith("hunter2", values);
    expect(form.validate({ password: "hunter22", confirm: "hunter22" })).toEqual({});
    
  5. calls a message function only when its test fails, with the value and the values
    const message = spy((value) => "Add " + (8 - value.length) + " more characters");
    const form = new FormValidator({ password: [{ test: (value) => value.length >= 8, message }] });
    const values = { password: "secret" };
    expect(form.validate(values)).toEqual({ password: "Add 2 more characters" });
    expect(message).toHaveBeenCalledWith("secret", values);
    form.validate({ password: "long enough" });
    expect(message).toHaveBeenCalledTimes(1);
    
  6. checks a missing field with undefined and ignores values without rules
    const test = spy((value) => value !== undefined && value !== "");
    const form = new FormValidator({ email: [{ test, message: "Enter your email" }] });
    expect(form.validate({ nickname: "" })).toEqual({ email: "Enter your email" });
    expect(test).toHaveBeenCalledWith(undefined, { nickname: "" });
    
  7. validateField returns one field's message, or null when it passes
    const other = spy(() => true);
    const form = new FormValidator({
      password: [{ test: (value) => value.length >= 8, message: "Too short" }],
      confirm: [{ test: (value, values) => value === values.password, message: "No match" }],
      email: [{ test: other, message: "Enter your email" }],
    });
    expect(form.validateField("password", { password: "abc", confirm: "abc" })).toBe("Too short");
    expect(form.validateField("confirm", { password: "abc", confirm: "abc" })).toBeNull();
    expect(form.validateField("confirm", { password: "abc", confirm: "abd" })).toBe("No match");
    expect(form.validateField("nickname", { nickname: "" })).toBeNull();
    expect(other).not.toHaveBeenCalled();
    
  8. isValid says whether every field passes
    const form = new FormValidator({
      age: [{ test: (value) => value >= 18, message: "Too young" }],
      name: [{ test: (value) => value !== "", message: "Enter your name" }],
    });
    expect(form.isValid({ age: 30, name: "Ada" })).toBe(true);
    expect(form.isValid({ age: 12, name: "Ada" })).toBe(false);
    expect(form.isValid({ age: 30, name: "" })).toBe(false);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).