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);
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);
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);
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);
Related
- Schemas overview - where the
formatkeyword fits in the broader keyword catalogue - JT keyword reference - json-tology specific keywords
validate- the entry point that runs format checks
See also
- Bookstore domain - schema definitions used in examples