Skip to content

Custom format validators

JSON Schema format keywords are pluggable. json-tology ships built-in validators for the standard formats (date, email, uri, uuid, int32, and so on) and lets you register your own through the formats constructor option.

A format validator is a function (value: unknown) => boolean. It receives the raw value (string, number, anything) and returns true when the value matches the format. The registry composes custom validators with the built-ins, so you can extend without redefining the standard set.

The bookstore domain in the Bookstore Domain is the running example. The book schema declares an ISBN with a regex; below we replace that with a real ISBN-10 format validator and add a slug format for review URLs.


Defining custom formats

Pass a formats map to JsonTology.create. Keys are format names; values are predicates.

/**
 * Custom formats — define and register validators on a JsonTology instance
 *
 * Pass a `formats` map to `JsonTology.create`. Keys are format names;
 * values are predicates. The example demonstrates an ISBN-10 checksum
 * validator and a slug validator, both wired against a fresh
 * `JsonTology` instance that registers the canonical bookstore
 * `BookSchema` alongside two new format-using primitives.
 */

import { JsonTology } from '../../../src/index.js';
import { BookSchema } from '../bookstore/index.js';

function isIsbn10(value: unknown): boolean {
  if (typeof value !== 'string' || value.length !== 10) {
    return false;
  }

  let sum = 0;

  for (let index = 0; index < 9; index += 1) {
    const codePoint = value.codePointAt(index);

    if (codePoint === undefined) {
      return false;
    }
    const digit = codePoint - 0x30;

    if (digit < 0 || digit > 9) {
      return false;
    }
    sum += digit * (10 - index);
  }

  const last = value.at(9);
  const lastCode = value.codePointAt(9);

  if (lastCode === undefined) {
    return false;
  }
  const check = last === 'X' ? 10 : lastCode - 0x30;

  if (check < 0 || check > 10) {
    return false;
  }
  sum += check;

  return sum % 11 === 0;
}

function isSlug(value: unknown): boolean {
  return typeof value === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(value);
}

const Isbn10Schema = {
  '$id': 'https://bookstore.example/Isbn10',
  'format': 'isbn-10',
  'type': 'string'
} as const;

const ReviewSlugSchema = {
  '$id': 'https://bookstore.example/ReviewSlug',
  'format': 'slug',
  'maxLength': 80,
  'minLength': 3,
  'type': 'string'
} as const;

const formats: Record<string, (value: unknown) => boolean> = {
  'isbn-10': isIsbn10,
  'slug': isSlug
};

const jt = JsonTology.create({
  'baseIri': 'https://bookstore.example',
  formats,
  'schemas': [
    BookSchema,
    Isbn10Schema,
    ReviewSlugSchema
  ] as const
});

// "0140449132" passes the ISBN-10 checksum (Penguin printing of
// "War and Peace" — the same edition the original docs example
// referenced).
const validIsbn = '0140449132';

console.assert(jt.validate(Isbn10Schema.$id, validIsbn).length === 0);
// '0140449131' fails the checksum (last digit should be 2).
console.assert(jt.validate(Isbn10Schema.$id, '0140449131').length > 0);
console.assert(jt.validate(ReviewSlugSchema.$id, 'die-unendliche-geschichte').length === 0);
console.assert(jt.validate(ReviewSlugSchema.$id, 'NotASlug').length > 0);
// 0
console.log('valid ISBN-10 errors:', jt.validate(Isbn10Schema.$id, validIsbn).length);
// > 0
console.log('bad ISBN-10 errors:', jt.validate(Isbn10Schema.$id, '0140449131').length);
// 0
console.log('valid slug errors:', jt.validate(ReviewSlugSchema.$id, 'die-unendliche-geschichte').length);
// > 0
console.log('bad slug errors:', jt.validate(ReviewSlugSchema.$id, 'NotASlug').length);

Output
Press Execute to run this example against the real library.

isIsbn10 and isSlug accept unknown because FormatRegistry calls them with the raw value. Always check the type before doing format-specific work - the same validator can be reached for non-string fields if a schema misuses the format.

Composing with bookstore schemas

The standard BookSchema declares ISBN with a 13-digit pattern. Swap the pattern for the new format on a refined schema and reuse the rest of the bookstore registration:

/**
 * Custom formats — compose with the bookstore registry
 *
 * The canonical `BookSchema` declares ISBN with a 13-digit pattern.
 * A separate `StrictBookSchema` swaps the pattern for the ISBN-10
 * format check declared in `36-custom-formats-define.ts`. Both
 * schemas register against a fresh JsonTology instance that loads
 * the same custom format map.
 */

import { JsonTology } from '../../../src/index.js';
import {
  AddressSchema, CustomerSchema, OrderLineSchema, OrderSchema, ReviewSchema
} from '../bookstore/index.js';

function isIsbn10(value: unknown): boolean {
  if (typeof value !== 'string' || value.length !== 10) {
    return false;
  }
  let sum = 0;

  for (let index = 0; index < 9; index += 1) {
    const codePoint = value.codePointAt(index);

    if (codePoint === undefined) {
      return false;
    }
    const digit = codePoint - 0x30;

    if (digit < 0 || digit > 9) {
      return false;
    }
    sum += digit * (10 - index);
  }
  const last = value.at(9);
  const lastCode = value.codePointAt(9);

  if (lastCode === undefined) {
    return false;
  }
  const check = last === 'X' ? 10 : lastCode - 0x30;

  if (check < 0 || check > 10) {
    return false;
  }
  sum += check;

  return sum % 11 === 0;
}

function isSlug(value: unknown): boolean {
  return typeof value === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(value);
}

const StrictBookSchema = {
  '$id': 'https://bookstore.example/StrictBook',
  'properties': {
    'authors': {
      'items': { 'type': 'string' },
      'minItems': 1,
      'type': 'array'
    },
    // ← was a 13-digit regex on the canonical BookSchema
    'isbn': {
      'format': 'isbn-10',
      'type': 'string'
    },
    'price': {
      'exclusiveMinimum': 0,
      'type': 'number'
    },
    'title': { 'type': 'string' }
  },
  'required': [
    'isbn',
    'title',
    'authors',
    'price'
  ],
  'type': 'object'
} as const;

const formats: Record<string, (value: unknown) => boolean> = {
  'isbn-10': isIsbn10,
  'slug': isSlug
};

const jt = JsonTology.create({
  'baseIri': 'https://bookstore.example',
  // doc example with synthetic fixture schemas
  'enableStrictGraph': false,
  formats,
  'schemas': [
    AddressSchema,
    CustomerSchema,
    OrderLineSchema,
    OrderSchema,
    ReviewSchema,
    StrictBookSchema
  ] as const
});

// "0140449132" passes the ISBN-10 checksum.
const errs = jt.validate(StrictBookSchema.$id, {
  'authors': ['Hermann Hesse'],
  'isbn': '0140449132',
  'price': 18.99,
  'title': 'Steppenwolf'
});

console.assert(errs.length === 0);
// 0 — ISBN-10 format check passes
console.log('StrictBook validation errors:', errs.length);

const isbn13Errs = jt.validate(StrictBookSchema.$id, {
  'authors': ['Hermann Hesse'],
  'isbn': '9780141182490',
  'price': 18.99,
  'title': 'Steppenwolf'
});

// > 0 — wrong length fails
console.log('ISBN-13 on ISBN-10 schema errors:', isbn13Errs.length);
Output
Press Execute to run this example against the real library.

Replacing a built-in format

Built-ins live under the same names (date, email, uuid, ...). Registering a custom validator under one of those names replaces the built-in for this JsonTology instance only. Other instances retain the built-ins.

/**
 * Custom formats — replace a built-in validator
 *
 * Built-ins live under the same names (`date`, `email`, `uuid`, ...).
 * Registering a custom validator under one of those names replaces
 * the built-in for that `JsonTology` instance only. Other instances
 * (including the canonical `bookstoreEntities`) retain the built-ins.
 *
 * Demonstrated against `EmailSchema` reused from the bookstore.
 */

import { JsonTology } from '../../../src/index.js';
import { EmailSchema } from '../bookstore/index.js';

const formats: Record<string, (value: unknown) => boolean> = {
  // Replace the built-in 'email' with a stricter rule that requires
  // a two-letter-plus TLD.
  'email': (value) => {
    return typeof value === 'string'
      && /^[^@\s]+@[^@\s]+\.[a-z]{2,}$/iu.test(value);
  }
};

const jt = JsonTology.create({
  'baseIri': 'https://bookstore.example',
  formats,
  'schemas': [EmailSchema] as const
});

// Bastian's bookstore email matches the stricter rule.
const okErrs = jt.validate(EmailSchema.$id, 'bastian.bux@bookstore.example');

console.assert(okErrs.length === 0);

// An email lacking a TLD fails the stricter rule.
const badErrs = jt.validate(EmailSchema.$id, 'bastian@localhost');

console.assert(badErrs.length > 0);
// 0 — .example TLD satisfies stricter rule
console.log('valid email errors:', okErrs.length);
// > 0 — no TLD fails custom validator
console.log('localhost email errors:', badErrs.length);
Output
Press Execute to run this example against the real library.

Number formats

The formats map handles number formats too. The validator receives the number.

/**
 * Custom formats — number formats
 *
 * The `formats` map handles number formats too. The validator
 * receives the number value directly. Demonstrated via a fresh
 * JsonTology instance that registers a `positive-int` format and a
 * matching schema derived from the bookstore `QuantitySchema`.
 */

import { JsonTology } from '../../../src/index.js';
import { QuantitySchema } from '../bookstore/index.js';

const PositiveCountSchema = {
  '$id': 'https://bookstore.example/PositiveCount',
  'format': 'positive-int',
  'type': 'integer'
} as const;

function isPositiveInt(value: unknown): boolean {
  return typeof value === 'number' && Number.isInteger(value) && value > 0;
}

const formats: Record<string, (value: unknown) => boolean> = { 'positive-int': isPositiveInt };

const jt = JsonTology.create({
  'baseIri': 'https://bookstore.example',
  formats,
  'schemas': [
    PositiveCountSchema,
    QuantitySchema
  ] as const
});

// Bastian's order line carries quantity 1 — a positive integer.
console.assert(jt.validate(PositiveCountSchema.$id, 1).length === 0);
console.assert(jt.validate(PositiveCountSchema.$id, 0).length > 0);
console.assert(jt.validate(PositiveCountSchema.$id, -3).length > 0);
// 0
console.log('quantity 1 errors:', jt.validate(PositiveCountSchema.$id, 1).length);
// > 0 — not positive
console.log('quantity 0 errors:', jt.validate(PositiveCountSchema.$id, 0).length);
// > 0 — negative
console.log('quantity -3 errors:', jt.validate(PositiveCountSchema.$id, -3).length);
Output
Press Execute to run this example against the real library.

See also

Released under the MIT License.