Skip to content
aviral gupta

// I4.5 · ~45 min · Intermediate

Build: a tested validation library

In this build you write validate.js, a library that checks form data against rules, reports every problem at once, and comes with its own tests.

Lesson 5 of 5 in I4 Errors, debugging and testing

End of the module

You will be able to

  • Write rules as small functions and report a broken rule as a ValidationError with its field
  • Collect every problem in an AggregateError, and keep a JSON SyntaxError as the cause
  • Test the library with node:test: which error is thrown, its fields and its messages
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson I4.2. What does this print?

    class ValidationError extends Error {
      constructor(field, message) {
        super(`${field}: ${message}`);
        this.name = "ValidationError";
        this.field = field;
      }
    }
    const error = new ValidationError("age", "is required");
    console.log(String(error), "|", error.field);
  2. Predict · Activity 2 of 7

    Predict before you read on. This first version of validate throws at the first problem. The form has three. What does it print?

    import {validate, required, minLength, integer} from "./validate.js";
    
    const schema = {name: [minLength(2)], age: [integer(16, 120)], city: [required()]};
    try {
      validate(schema, {name: "A", age: 15, city: ""});
    } catch (error) {
      console.log(error.message);
    }
  3. Practice · Activity 3 of 7

    Fill in the error class that carries a whole array of errors.

    throw new ____(errors, "2 invalid fields");
    throw new (errors, "2 invalid fields");
  4. Practice · Activity 4 of 7

    Match each part of the library to its job.

  5. Practice · Activity 5 of 7

    minLength(2) returns a check. What does this print?

    import {minLength} from "./validate.js";
    
    const atLeast2 = minLength(2);
    console.log(atLeast2("Al"), "|", atLeast2("A"));
  6. Brain teaser · Activity 6 of 7

    Brain teaser. The age comes from a form field, as the browser sends it. What does this print?

    import {validate, integer} from "./validate.js";
    
    const form = {age: "36"};
    try {
      validate({age: [integer(16, 120)]}, form);
      console.log("ok");
    } catch (error) {
      console.log(error.errors[0].message);
    }
  7. Apply · Activity 7 of 7

    Mini-task: add a rule maxLength(max) to validate.js, with the problem text must have at most <max> characters. Then write validate.test.js with two tests: a short name passes and comes back unchanged, and a long name throws an AggregateError whose first error says name: must have at most 5 characters. Run node --test.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

The finished library on three forms

main.js validates three sign-up forms against one schema with validate.js. The first is fine, the second breaks a rule in every field, the third has no name. Every problem is listed, with one message per field.

main.js

import {validate, required, minLength, integer, oneOf} from "./validate.js";

const signup = {
  name: [required(), minLength(2)],
  age: [required(), integer(16, 120)],
  plan: [required(), oneOf(["free", "team"])]
};

const forms = [
  {name: "Ada", age: 36, plan: "free"},
  {name: "A", age: 15.5, plan: "gold"},
  {age: 30, plan: "team"}
];

for (const form of forms) {
  try {
    validate(signup, form);
    console.log("ok:", form.name);
  } catch (error) {
    if (!(error instanceof AggregateError)) throw error;
    console.log(error.message);
    for (const problem of error.errors) console.log("  " + problem.message);
  }
}

validate.js

export class ValidationError extends Error {
  constructor(field, message, options) {
    super(`${field}: ${message}`, options);
    this.name = "ValidationError";
    this.field = field;
  }
}

// Each rule returns a check: a function that gets a value and returns
// the problem as text, or null when the value is fine.
export const required = () => (value) =>
  value === undefined || value === null || value === "" ? "is required" : null;

export const minLength = (min) => (value) =>
  typeof value === "string" && value.length >= min ? null : `must have at least ${min} characters`;

export const integer = (min, max) => (value) =>
  Number.isInteger(value) && value >= min && value <= max ? null : `must be a whole number from ${min} to ${max}`;

export const oneOf = (choices) => (value) =>
  choices.includes(value) ? null : `must be one of ${choices.join(", ")}`;

export function validate(schema, data) {
  const errors = [];
  for (const [field, checks] of Object.entries(schema)) {
    for (const check of checks) {
      const problem = check(data[field]);
      if (problem !== null) {
        errors.push(new ValidationError(field, problem));
        break; // one problem per field is enough
      }
    }
  }
  if (errors.length > 0) {
    throw new AggregateError(errors, `${errors.length} invalid field${errors.length === 1 ? "" : "s"}`);
  }
  return data;
}

export function parseBody(text, schema) {
  let data;
  try {
    data = JSON.parse(text);
  } catch (error) {
    throw new ValidationError("body", "is not valid JSON", {cause: error});
  }
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
    throw new ValidationError("body", "must be a JSON object");
  }
  return validate(schema, data);
}

Run it with

node main.js

Output

ok: Ada
3 invalid fields
  name: must have at least 2 characters
  age: must be a whole number from 16 to 120
  plan: must be one of free, team
1 invalid field
  name: is required
  • The third form lacks name, and required() stops there: it does not also say "must have at least 2 characters".
  • main.js only handles AggregateError; any other error is thrown on, because it would be a bug.
  • The schema is plain data, so a new form needs a new schema, not a new validate.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Exercises

Exercise 1 of 3

Every problem at once

main.js holds the library, validate.js from the example. Its validate stops at the first broken rule and throws its ValidationError. Change it so that it collects one ValidationError per broken field (only the first problem of each field) and then throws one AggregateError with the message <n> invalid field, or <n> invalid fields for more than one. Valid data is returned unchanged.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Start with const errors = [] and push a new ValidationError instead of throwing it.

  2. Hint 2

    After pushing, break out of the inner loop, so a field reports only its first problem.

  3. Hint 3

    After both loops, throw new AggregateError(errors, message) if errors is not empty.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export class ValidationError extends Error {
  constructor(field, message, options) {
    super(`${field}: ${message}`, options);
    this.name = "ValidationError";
    this.field = field;
  }
}

// Each rule returns a check: a function that gets a value and returns
// the problem as text, or null when the value is fine.
export const required = () => (value) =>
  value === undefined || value === null || value === "" ? "is required" : null;

export const minLength = (min) => (value) =>
  typeof value === "string" && value.length >= min ? null : `must have at least ${min} characters`;

export const integer = (min, max) => (value) =>
  Number.isInteger(value) && value >= min && value <= max ? null : `must be a whole number from ${min} to ${max}`;

export const oneOf = (choices) => (value) =>
  choices.includes(value) ? null : `must be one of ${choices.join(", ")}`;

export function validate(schema, data) {
  const errors = [];
  for (const [field, checks] of Object.entries(schema)) {
    for (const check of checks) {
      const problem = check(data[field]);
      if (problem !== null) {
        errors.push(new ValidationError(field, problem));
        break; // one problem per field is enough
      }
    }
  }
  if (errors.length > 0) {
    throw new AggregateError(errors, `${errors.length} invalid field${errors.length === 1 ? "" : "s"}`);
  }
  return data;
}

export function parseBody(text, schema) {
  let data;
  try {
    data = JSON.parse(text);
  } catch (error) {
    throw new ValidationError("body", "is not valid JSON", {cause: error});
  }
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
    throw new ValidationError("body", "must be a JSON object");
  }
  return validate(schema, data);
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export class ValidationError extends Error {
  constructor(field, message, options) {
    super(`${field}: ${message}`, options);
    this.name = "ValidationError";
    this.field = field;
  }
}

// Each rule returns a check: a function that gets a value and returns
// the problem as text, or null when the value is fine.
export const required = () => (value) =>
  value === undefined || value === null || value === "" ? "is required" : null;

export const minLength = (min) => (value) =>
  typeof value === "string" && value.length >= min ? null : `must have at least ${min} characters`;

export const integer = (min, max) => (value) =>
  Number.isInteger(value) && value >= min && value <= max ? null : `must be a whole number from ${min} to ${max}`;

export const oneOf = (choices) => (value) =>
  choices.includes(value) ? null : `must be one of ${choices.join(", ")}`;

export function validate(schema, data) {
  for (const [field, checks] of Object.entries(schema)) {
    for (const check of checks) {
      const problem = check(data[field]);
      if (problem !== null) throw new ValidationError(field, problem);
    }
  }
  return data;
}

export function parseBody(text, schema) {
  let data;
  try {
    data = JSON.parse(text);
  } catch (error) {
    throw new ValidationError("body", "is not valid JSON", {cause: error});
  }
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
    throw new ValidationError("body", "must be a JSON object");
  }
  return validate(schema, data);
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {validate, required, minLength, integer} from './main.js';

/** The error that fn throws; the test fails when fn throws nothing. */
function caught(fn) {
  try {
    fn();
  } catch (error) {
    return error;
  }
  assert.fail('nothing was thrown');
}

const schema = {name: [required(), minLength(2)], age: [required(), integer(16, 120)]};

test('valid data comes back unchanged', () => {
  assert.deepEqual(validate(schema, {name: 'Ada', age: 36}), {name: 'Ada', age: 36}, 'validate should return the data');
});

test('two broken fields give one AggregateError with two errors', () => {
  const error = caught(() => validate(schema, {name: 'A', age: 15}));
  assert.ok(error instanceof AggregateError, `validate threw ${error.name}, not an AggregateError`);
  assert.equal(error.message, '2 invalid fields');
  assert.deepEqual(error.errors.map((e) => e.field), ['name', 'age']);
});

test('one problem per field, and the singular for one field', () => {
  const error = caught(() => validate(schema, {age: 30}));
  assert.ok(error instanceof AggregateError, `validate threw ${error.name}, not an AggregateError`);
  assert.equal(error.message, '1 invalid field');
  assert.deepEqual(error.errors.map((e) => e.message), ['name: is required']);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 3

A rule of your own: oneOf

main.js holds the library, validate.js from the example. oneOf(choices) should return a check that accepts only the listed values and otherwise returns must be one of followed by the choices, separated by a comma and a space: must be one of free, team. In the starter, the check accepts everything.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Look at minLength: a function that returns a function of value.

  2. Hint 2

    choices.includes(value) tells whether the value is listed.

  3. Hint 3

    choices.join(", ") builds the list for the message.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export class ValidationError extends Error {
  constructor(field, message, options) {
    super(`${field}: ${message}`, options);
    this.name = "ValidationError";
    this.field = field;
  }
}

// Each rule returns a check: a function that gets a value and returns
// the problem as text, or null when the value is fine.
export const required = () => (value) =>
  value === undefined || value === null || value === "" ? "is required" : null;

export const minLength = (min) => (value) =>
  typeof value === "string" && value.length >= min ? null : `must have at least ${min} characters`;

export const integer = (min, max) => (value) =>
  Number.isInteger(value) && value >= min && value <= max ? null : `must be a whole number from ${min} to ${max}`;

export const oneOf = (choices) => (value) =>
  choices.includes(value) ? null : `must be one of ${choices.join(", ")}`;

export function validate(schema, data) {
  const errors = [];
  for (const [field, checks] of Object.entries(schema)) {
    for (const check of checks) {
      const problem = check(data[field]);
      if (problem !== null) {
        errors.push(new ValidationError(field, problem));
        break; // one problem per field is enough
      }
    }
  }
  if (errors.length > 0) {
    throw new AggregateError(errors, `${errors.length} invalid field${errors.length === 1 ? "" : "s"}`);
  }
  return data;
}

export function parseBody(text, schema) {
  let data;
  try {
    data = JSON.parse(text);
  } catch (error) {
    throw new ValidationError("body", "is not valid JSON", {cause: error});
  }
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
    throw new ValidationError("body", "must be a JSON object");
  }
  return validate(schema, data);
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export class ValidationError extends Error {
  constructor(field, message, options) {
    super(`${field}: ${message}`, options);
    this.name = "ValidationError";
    this.field = field;
  }
}

// Each rule returns a check: a function that gets a value and returns
// the problem as text, or null when the value is fine.
export const required = () => (value) =>
  value === undefined || value === null || value === "" ? "is required" : null;

export const minLength = (min) => (value) =>
  typeof value === "string" && value.length >= min ? null : `must have at least ${min} characters`;

export const integer = (min, max) => (value) =>
  Number.isInteger(value) && value >= min && value <= max ? null : `must be a whole number from ${min} to ${max}`;

export const oneOf = (choices) => (value) => null; // TODO

export function validate(schema, data) {
  const errors = [];
  for (const [field, checks] of Object.entries(schema)) {
    for (const check of checks) {
      const problem = check(data[field]);
      if (problem !== null) {
        errors.push(new ValidationError(field, problem));
        break; // one problem per field is enough
      }
    }
  }
  if (errors.length > 0) {
    throw new AggregateError(errors, `${errors.length} invalid field${errors.length === 1 ? "" : "s"}`);
  }
  return data;
}

export function parseBody(text, schema) {
  let data;
  try {
    data = JSON.parse(text);
  } catch (error) {
    throw new ValidationError("body", "is not valid JSON", {cause: error});
  }
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
    throw new ValidationError("body", "must be a JSON object");
  }
  return validate(schema, data);
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {validate, oneOf} from './main.js';

/** The error that fn throws; the test fails when fn throws nothing. */
function caught(fn) {
  try {
    fn();
  } catch (error) {
    return error;
  }
  assert.fail('nothing was thrown');
}

test('a listed value is fine', () => {
  assert.equal(oneOf(['free', 'team'])('team'), null, 'team is one of the choices');
});

test('another value names the choices', () => {
  assert.equal(oneOf(['free', 'team'])('gold'), 'must be one of free, team');
});

test('validate reports the field', () => {
  const error = caught(() => validate({plan: [oneOf(['free', 'team'])]}, {plan: 'gold'}));
  assert.deepEqual(error.errors?.map((e) => e.message), ['plan: must be one of free, team']);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 3 of 3

Broken JSON is a ValidationError

main.js holds the library, validate.js from the example. parseBody(text, schema) reads a request body as JSON and validates it. Broken JSON now escapes as a bare SyntaxError, and the body null crashes validate. Make parseBody throw a ValidationError for the field body: is not valid JSON, with the SyntaxError as its cause, and must be a JSON object when the JSON is not an object (null, an array, a number or a string).

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Put JSON.parse in try, and throw the new ValidationError in catch, passing {cause: error}.

  2. Hint 2

    typeof null is "object" too, so check data === null separately.

  3. Hint 3

    Array.isArray(data) tells an array apart from an object.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export class ValidationError extends Error {
  constructor(field, message, options) {
    super(`${field}: ${message}`, options);
    this.name = "ValidationError";
    this.field = field;
  }
}

// Each rule returns a check: a function that gets a value and returns
// the problem as text, or null when the value is fine.
export const required = () => (value) =>
  value === undefined || value === null || value === "" ? "is required" : null;

export const minLength = (min) => (value) =>
  typeof value === "string" && value.length >= min ? null : `must have at least ${min} characters`;

export const integer = (min, max) => (value) =>
  Number.isInteger(value) && value >= min && value <= max ? null : `must be a whole number from ${min} to ${max}`;

export const oneOf = (choices) => (value) =>
  choices.includes(value) ? null : `must be one of ${choices.join(", ")}`;

export function validate(schema, data) {
  const errors = [];
  for (const [field, checks] of Object.entries(schema)) {
    for (const check of checks) {
      const problem = check(data[field]);
      if (problem !== null) {
        errors.push(new ValidationError(field, problem));
        break; // one problem per field is enough
      }
    }
  }
  if (errors.length > 0) {
    throw new AggregateError(errors, `${errors.length} invalid field${errors.length === 1 ? "" : "s"}`);
  }
  return data;
}

export function parseBody(text, schema) {
  let data;
  try {
    data = JSON.parse(text);
  } catch (error) {
    throw new ValidationError("body", "is not valid JSON", {cause: error});
  }
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
    throw new ValidationError("body", "must be a JSON object");
  }
  return validate(schema, data);
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export class ValidationError extends Error {
  constructor(field, message, options) {
    super(`${field}: ${message}`, options);
    this.name = "ValidationError";
    this.field = field;
  }
}

// Each rule returns a check: a function that gets a value and returns
// the problem as text, or null when the value is fine.
export const required = () => (value) =>
  value === undefined || value === null || value === "" ? "is required" : null;

export const minLength = (min) => (value) =>
  typeof value === "string" && value.length >= min ? null : `must have at least ${min} characters`;

export const integer = (min, max) => (value) =>
  Number.isInteger(value) && value >= min && value <= max ? null : `must be a whole number from ${min} to ${max}`;

export const oneOf = (choices) => (value) =>
  choices.includes(value) ? null : `must be one of ${choices.join(", ")}`;

export function validate(schema, data) {
  const errors = [];
  for (const [field, checks] of Object.entries(schema)) {
    for (const check of checks) {
      const problem = check(data[field]);
      if (problem !== null) {
        errors.push(new ValidationError(field, problem));
        break; // one problem per field is enough
      }
    }
  }
  if (errors.length > 0) {
    throw new AggregateError(errors, `${errors.length} invalid field${errors.length === 1 ? "" : "s"}`);
  }
  return data;
}

export function parseBody(text, schema) {
  return validate(schema, JSON.parse(text));
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {parseBody, ValidationError, required} from './main.js';

/** The error that fn throws; the test fails when fn throws nothing. */
function caught(fn) {
  try {
    fn();
  } catch (error) {
    return error;
  }
  assert.fail('nothing was thrown');
}

const schema = {name: [required()]};

test('a valid body comes back as an object', () => {
  assert.deepEqual(parseBody('{"name": "Ada"}', schema), {name: 'Ada'});
});

test('broken JSON is a ValidationError with the SyntaxError as cause', () => {
  const error = caught(() => parseBody('{name', schema));
  assert.ok(error instanceof ValidationError, `parseBody threw ${error.name}`);
  assert.equal(error.message, 'body: is not valid JSON');
  assert.ok(error.cause instanceof SyntaxError, 'the SyntaxError should be the cause');
});

test('null and arrays are not objects', () => {
  for (const text of ['null', '[1, 2]']) {
    assert.throws(() => parseBody(text, schema), {name: 'ValidationError', message: 'body: must be a JSON object'}, `for ${text}`);
  }
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Reading error.errors from any error

import {parseBody, required} from "./validate.js";

try {
  parseBody("{name", {name: [required()]});
} catch (error) {
  for (const problem of error.errors) console.log(problem.message);
}

What Node.js prints

TypeError: error.errors is not iterable

Why, and the fix

Only an AggregateError has an errors array. Here parseBody threw a single ValidationError for the broken JSON, so error.errors is undefined. Check the type first: if (error instanceof AggregateError) list error.errors, else if (error instanceof ValidationError) show error.message, and throw anything else on.

Validating null

import {validate, required} from "./validate.js";

const data = JSON.parse("null");
validate({name: [required()]}, data);

What Node.js prints

TypeError: Cannot read properties of null (reading 'name')

Why, and the fix

"null" is valid JSON, and so are arrays and numbers, so a successful JSON.parse does not mean you have an object. validate reads data[field] and crashes on null. Check the shape first, as parseBody does: throw a ValidationError when typeof data is not "object", data is null, or it is an array.

Importing a rule the library does not export

import {validate, required, maxLength} from "./validate.js";

validate({name: [required(), maxLength(5)]}, {name: "Ada"});

What Node.js prints

SyntaxError: The requested module './validate.js' does not provide an export named 'maxLength'

Why, and the fix

The import is checked before any code runs, and validate.js has no export called maxLength yet. Add export const maxLength = … to validate.js (the mini-task does), or import only names that exist.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Rules are functions

A rule such as minLength(2) returns a check: a function that gets a value and returns the problem as text, or null when the value is fine. A schema lists the checks per field: {name: [required(), minLength(2)]}. validate(schema, data) runs them in order and stops at the first problem of each field, so a missing name says "is required" and not also "must have at least 2 characters". Adding a rule never changes validate.

Every problem at once

A form with three mistakes should get three messages, not one per try. validate collects one ValidationError per broken field, each with a field property, and then throws a single AggregateError whose errors array holds them all. The caller checks error instanceof AggregateError and lists error.errors. parseBody(text, schema) turns broken JSON into a ValidationError for the field body, with the SyntaxError kept as its cause.

A library comes with tests

validate.test.js imports validate.js and checks it with node --test. assert.throws takes a class, or an object whose properties must match, such as {name: "ValidationError", field: "body"}, or a function that checks the error itself and returns true. Compare the list of fields with deepEqual. A test that only catches an error and asserts inside catch passes when nothing is thrown, so prefer assert.throws.

Sources

Last reviewed October 5, 2026