JSON Schema Custom Keywords: Extending Ajv and Validators

Last updated:

JSON Schema does not include every validation rule you need — custom keywords let you add domain-specific constraints like uniqueItemProperties, instanceof, or currency directly into schemas. Rather than writing a separate validation layer, you embed the rule in the schema itself so tooling, documentation, and validation stay in sync.

Ajv (the fastest JSON Schema validator, 90k+ validations/sec) supports custom keywords via ajv.addKeyword(). A keyword can be a validate function that runs directly on the data, a compile function that returns a pre-built validator closure, or a macro that expands to standard JSON Schema before validation even begins.

This guide covers all three Ajv custom keyword types, the ajv-keywords plugin (15+ pre-built extras), and how to write type-safe custom keywords with TypeScript. Use the Jsonic JSON Schema Validator to test your schemas interactively as you follow along.

Why extend JSON Schema?

The built-in JSON Schema vocabulary covers types, string patterns, numeric ranges, and array constraints — but many real-world validation requirements go further. Three categories of rules routinely fall outside standard JSON Schema:

Domain-specific constraints. A product price must always be a multiple of 0.01 (cent precision). A color field must be a valid CSS hex value. A VAT number must follow a country-specific format that a simple regex cannot express. These rules are too narrow for the spec to standardize, yet they appear in every production API.

Cross-field rules. Standard JSON Schema validates each field in isolation. There is no built-in way to say "the endDate must be after startDate" or "the confirmPassword must equal password". Custom keywords receive either the parent object or use $data references to read sibling values.

Async database lookups. Uniqueness constraints — "this email is not already registered", "this username is not taken" — require a database query. Ajv's async keyword support lets you include these checks inside the schema validation step rather than layering them as a separate pass. This keeps your validation logic co-located with your schema and ensures async checks run alongside all other constraints in a single await call.

Custom keywords are compiled into Ajv's generated validator function at compile time, so they impose zero per-call overhead beyond the function body itself. The keyword fires as part of the same tight validation loop as type and required.

validate function keyword

A validatekeyword is the simplest custom keyword type. You provide a function that receives the keyword's schema value and the data value being validated, and returns a boolean. Call ajv.addKeyword() before any ajv.compile() calls — keywords registered after compilation are not included in already-compiled validators.

import Ajv from 'ajv'
const ajv = new Ajv({ allErrors: true })

// Keyword: value must be a multiple of a given step
ajv.addKeyword({
  keyword: 'multipleOfStep',
  validate: function (step: number, data: unknown) {
    if (typeof data !== 'number') return true  // let 'type' keyword handle it
    return Math.round(data / step) === data / step
  },
  errors: false,  // Ajv will generate a generic error message
  metaSchema: {   // optional: validate the keyword's schema value
    type: 'number',
    exclusiveMinimum: 0,
  },
})

// Use in a schema
const priceSchema = {
  type: 'number',
  minimum: 0,
  multipleOfStep: 0.01,   // must have at most 2 decimal places
}

const validate = ajv.compile(priceSchema)
console.log(validate(9.99))    // true
console.log(validate(9.999))   // false

To emit a custom error message instead of Ajv's generic one, set errors: true and push to the validate.errors array inside your function:

ajv.addKeyword({
  keyword: 'currency',
  validate: function validateCurrency(
    schema: boolean,
    data: unknown,
    _parentSchema: unknown,
    dataCxt: { parentData: Record<string, unknown>; parentDataProperty: string }
  ) {
    const valid = typeof data === 'string' && /^[A-Z]{3}$/.test(data)
    if (!valid) {
      validateCurrency.errors = [
        {
          keyword: 'currency',
          message: 'must be a 3-letter ISO 4217 currency code (e.g. USD, EUR)',
          params: { currency: data },
          instancePath: '',
          schemaPath: '#/currency',
        },
      ]
    }
    return valid
  },
  errors: true,
})

compile function keyword

A compilekeyword is used when the keyword's schema value needs to be pre-processed once at compile time rather than on every validation call. The compile function receives the schema value and returns a validator function (closure). Ajv calls the compile function once when building the validator; the returned closure is then called on every data value during validation.

This is ideal for keywords whose schema value requires computation — building a regex, parsing a range, or constructing a lookup set:

ajv.addKeyword({
  keyword: 'regexp',
  compile(schemaVal: string | { pattern: string; flags?: string }) {
    // Pre-build the RegExp ONCE at compile time
    const pattern = typeof schemaVal === 'string' ? schemaVal : schemaVal.pattern
    const flags   = typeof schemaVal === 'string' ? '' : (schemaVal.flags ?? '')
    const re = new RegExp(pattern, flags)

    // Return the validator closure — called thousands of times per second
    return function validate(data: unknown) {
      return typeof data !== 'string' || re.test(data)
    }
  },
  errors: false,
  metaSchema: {
    oneOf: [
      { type: 'string' },
      {
        type: 'object',
        properties: {
          pattern: { type: 'string' },
          flags:   { type: 'string', pattern: '^[gimsuy]*$' },
        },
        required: ['pattern'],
      },
    ],
  },
})

// Usage
const schema = {
  type: 'string',
  regexp: { pattern: '^[A-Z]{2}-[0-9]{4}$', flags: 'i' },
}
const validate = ajv.compile(schema)
console.log(validate('GB-1234'))  // true
console.log(validate('1234'))     // false

The compile function pattern matters for performance. Building a RegExp inside a validate function would reconstruct it on every data value. With compile, construction happens once, and the closure captures the pre-built object for the lifetime of the validator. The same technique applies to sorted lookup arrays, parsed ranges, or any schema-derived constant that would be expensive to recompute per-call.

macro keyword

A macro keyword is the most portable custom keyword type. Instead of running a function at validation time, a macro transforms its schema value into standard JSON Schema. Ajv merges the generated schema into the parent schema and processes it with built-in keywords — no custom runtime code runs during validation.

Because macro keywords expand to standard JSON Schema before validation, the expanded schema can be serialized and used with any compliant validator (Ajv, Zod-to-JSON-Schema, jsonschema, etc.) without any custom keyword support on the consumer side.

// macro: 'range' expands to { minimum, maximum }
ajv.addKeyword({
  keyword: 'range',
  macro([min, max]: [number, number]) {
    return { minimum: min, maximum: max }
  },
  metaSchema: {
    type: 'array',
    items: [{ type: 'number' }, { type: 'number' }],
    minItems: 2,
    maxItems: 2,
  },
})

// These two schemas are equivalent after macro expansion:
const withMacro    = { type: 'number', range: [1, 100] }
const withBuiltins = { type: 'number', minimum: 1, maximum: 100 }

// Another macro: 'exclusiveRange' expands to exclusiveMinimum / exclusiveMaximum
ajv.addKeyword({
  keyword: 'exclusiveRange',
  macro([min, max]: [number, number]) {
    return { exclusiveMinimum: min, exclusiveMaximum: max }
  },
  metaSchema: {
    type: 'array',
    items: [{ type: 'number' }, { type: 'number' }],
    minItems: 2,
    maxItems: 2,
  },
})

const validate = ajv.compile({ type: 'number', range: [0, 1] })
console.log(validate(0.5))  // true
console.log(validate(1.5))  // false

Macro keywords also compose naturally — a macro's expansion can reference other custom keywords, enabling layered domain vocabularies. A schema author can define a positiveAmount macro that expands to { "type": "number", "range": [0.01, 1000000] }, which itself expands to minimum and maximum. The full expansion happens at compile time with no runtime cost.

ajv-keywords plugin

The ajv-keywords package provides 15+ production-ready custom keywords that cover the most common extension needs. Install it alongside Ajv:

npm install ajv ajv-keywords
import Ajv from 'ajv'
import ajvKeywords from 'ajv-keywords'

const ajv = new Ajv({ allErrors: true })
ajvKeywords(ajv)   // add ALL keywords

// Or add only specific keywords (smaller bundle):
// ajvKeywords(ajv, ['instanceof', 'uniqueItemProperties', 'regexp'])

The most useful keywords from the package:

KeywordSchema valueWhat it validates
instanceof"Date", "RegExp", "Buffer"JS constructor check (data instanceof Date)
uniqueItemProperties["id", "slug"]Array items unique by one or more property names
range[1, 100]Inclusive numeric range (shorthand for minimum + maximum)
exclusiveRange[0, 1]Exclusive numeric range
regexp{ pattern: "^\\d+$" }String matches a regex (with optional flags)
transform["trim", "toLowerCase"]Mutates string data before validation (requires { modifying: true })
typeof"function"JavaScript typeof check (beyond JSON types)
// uniqueItemProperties: no two items may share the same 'id'
const teamSchema = {
  type: 'array',
  items: {
    type: 'object',
    properties: {
      id:   { type: 'string' },
      name: { type: 'string' },
    },
    required: ['id', 'name'],
  },
  uniqueItemProperties: ['id'],
}

const validate = ajv.compile(teamSchema)
console.log(validate([
  { id: 'a1', name: 'Alice' },
  { id: 'b2', name: 'Bob' },
]))   // true

console.log(validate([
  { id: 'a1', name: 'Alice' },
  { id: 'a1', name: 'Duplicate' },  // same id!
]))   // false

Async custom keywords

Some constraints can only be checked with I/O: verifying that an email address is not already registered, that a username is available, or that a referenced resource ID exists in the database. Ajv supports async custom keywords via async: true in the keyword definition. The validate function returns a Promise<boolean> instead of a boolean.

The parent schema must declare $async: true. The compiled validator then returns a Promise that resolves to the data on success and rejects with a ValidationError on failure — the same error format as synchronous validation.

import Ajv from 'ajv'
import addFormats from 'ajv-formats'

const ajv = new Ajv({ allErrors: true })
addFormats(ajv)

// Simulated DB lookup
async function isEmailUnique(email: string): Promise<boolean> {
  const existing = await db.users.findOne({ email })
  return existing === null
}

ajv.addKeyword({
  keyword: 'uniqueEmail',
  async: true,
  schema: false,   // keyword takes no schema value — presence is the flag
  validate: async function (_schema: unknown, email: string) {
    return isEmailUnique(email)
  },
})

const registrationSchema = {
  $async: true,
  type: 'object',
  properties: {
    email:    { type: 'string', format: 'email', uniqueEmail: true },
    password: { type: 'string', minLength: 8 },
  },
  required: ['email', 'password'],
}

// async compile works the same as sync compile
const validateAsync = ajv.compile(registrationSchema)

async function register(body: unknown) {
  try {
    const data = await validateAsync(body)
    // data is valid — proceed with registration
    return { success: true, data }
  } catch (err) {
    if (err instanceof Ajv.ValidationError) {
      return { success: false, errors: err.errors }
    }
    throw err  // unexpected error, re-throw
  }
}

Async keywords add latency equal to your I/O round-trip. Keep them focused: one keyword per distinct async check, and batch database calls inside the keyword function if you need to check multiple related constraints. Avoid adding async: true to keywords that do not actually need I/O — sync keywords run synchronously inside the compiled function, which is significantly faster.

TypeScript types for custom keywords

Ajv v8 ships full TypeScript types for custom keyword definitions. The key types are KeywordDefinition (the object passed to addKeyword), SchemaValidateFunction (the validate function signature), and FuncKeywordDefinition (for validate/compile keywords specifically).

import Ajv, { type KeywordDefinition } from 'ajv'
import type { ErrorObject } from 'ajv'

const ajv = new Ajv({ allErrors: true })

// Typed keyword definition
const positiveIntegerKeyword: KeywordDefinition = {
  keyword: 'positiveInteger',
  validate: function (schema: boolean, data: unknown): boolean {
    return Number.isInteger(data) && (data as number) > 0
  },
  errors: false,
}

ajv.addKeyword(positiveIntegerKeyword)

// Typed custom error objects
interface RangeError extends ErrorObject {
  keyword: 'betweenDates'
  params: { start: string; end: string; actual: string }
}

// validate function with typed errors
function validateBetweenDates(
  schema: { start: string; end: string },
  data: unknown
): boolean {
  const fn = validateBetweenDates as typeof validateBetweenDates & {
    errors?: RangeError[]
  }
  if (typeof data !== 'string') return true
  const d = new Date(data)
  const valid = d >= new Date(schema.start) && d <= new Date(schema.end)
  if (!valid) {
    fn.errors = [
      {
        keyword: 'betweenDates',
        message: `must be between ${schema.start} and ${schema.end}`,
        params: { start: schema.start, end: schema.end, actual: data },
        instancePath: '',
        schemaPath: '#/betweenDates',
      },
    ]
  }
  return valid
}

ajv.addKeyword({
  keyword: 'betweenDates',
  validate: validateBetweenDates,
  errors: true,
})

For schemas that use custom keywords with JSONSchemaType<T>, TypeScript will error because the custom keyword is not in the standard type. Extend the type or use a type assertion on the schema. The alternative is to declare your schema as const without the JSONSchemaType annotation and add the type parameter only to ajv.compile<T>(schema) — this avoids the conflict while still getting narrowed types on the returned validate function.

import { type JSONSchemaType } from 'ajv'

interface Booking {
  guestName: string
  checkIn:   string
  checkOut:  string
}

// Use 'as unknown as JSONSchemaType<Booking>' to include custom keywords
const bookingSchema = {
  type: 'object',
  properties: {
    guestName: { type: 'string', minLength: 1 },
    checkIn:   { type: 'string', format: 'date' },
    checkOut:  { type: 'string', format: 'date', betweenDates: { start: '2025-01-01', end: '2030-12-31' } },
  },
  required: ['guestName', 'checkIn', 'checkOut'],
} as unknown as JSONSchemaType<Booking>

const validate = ajv.compile<Booking>(bookingSchema)
// validate(data) is a type guard: narrows data to Booking on true

Glossary

Custom keyword
A user-defined JSON Schema keyword registered with a validator library like Ajv. Custom keywords extend the schema vocabulary beyond what the JSON Schema specification defines. They are Ajv-specific unless implemented as macros.
validate function
The simplest custom keyword type in Ajv. A function that receives the keyword's schema value and the data value and returns a boolean. Called directly during validation without pre-processing the schema value.
compile function
A custom keyword type where a factory function receives the schema value once at compile time and returns a validator closure. The closure captures any pre-computed state (regex, lookup table) and is called on every data value during validation.
macro keyword
A custom keyword type that transforms its schema value into standard JSON Schema. Ajv expands the macro at compile time and processes the result with built-in keywords. The most portable type — expanded schemas work in any JSON Schema validator.
$data reference
An Ajv extension that lets keyword values reference other fields in the validated data using JSON Pointer syntax. Enabled with new Ajv({ $data: true }). Example: { "minimum": { "$data": "1/minValue" } } reads the minValue sibling field as the minimum. Enables cross-field rules without custom keywords.
ajv-keywords
An npm package providing 15+ pre-built custom keywords for Ajv: instanceof, uniqueItemProperties, range, regexp, transform, typeof, and more. Apply with ajvKeywords(ajv) after creating your Ajv instance.
KeywordDefinition
The TypeScript type exported by Ajv for the object passed to ajv.addKeyword(). Includes discriminated union branches for validate, compile, and macro keyword types, with optional fields for async, errors, metaSchema, and schema.

FAQ

How do I add a custom validation keyword to Ajv?

Call ajv.addKeyword({ keyword: 'name', validate: fn }) before any ajv.compile() call. The keyword definition requires at minimum a keyword string and one of validate, compile, or macro. Set errors: false to let Ajv generate the error message, or errors: true to emit custom error objects from inside the function. Use metaSchemato validate the keyword's own schema value and catch authoring mistakes early. See the full Ajv JSON Schema validator guide for installation and instance setup.

What is the difference between validate, compile, and macro keywords in Ajv?

A validate keyword runs its function on every data value — simplest to write, best for stateless checks. A compile keyword runs a factory once at compile time to produce a closure — best when the schema value requires pre-processing (building a regex, parsing a range). A macro keyword transforms its schema value into standard JSON Schema — best for portability because no custom runtime code runs. When in doubt: use validate for simple checks, compile for performance-sensitive rules with expensive schema-time setup, and macro when you want the result to work in other validators.

Can I validate one field against another field's value in JSON Schema?

Standard JSON Schema cannot reference sibling fields. Ajv provides two solutions. First, enable $data references with new Ajv({ $data: true }) — then write { "minimum": { "$data": "1/minPrice" } }to read a sibling's value at validation time. Second, write a custom keyword with schema: false that receives the parent object and checks both fields together. The $data approach is declarative and works with any keyword that Ajv supports; the custom keyword approach is more flexible for multi-field rules. See JSON Schema validation keywords for the full list of built-in constraints.

How do I use the ajv-keywords package?

Install with npm install ajv-keywords, then call ajvKeywords(ajv) after creating your Ajv instance and before any compile() calls. To add only specific keywords, pass an array: ajvKeywords(ajv, ['instanceof', 'uniqueItemProperties']). The most commonly used keywords are uniqueItemProperties (unique array items by field), regexp (regex with flags), range (inclusive numeric range), and transform (mutate strings before validation). Check the validate JSON Schema in JavaScript guide for a comparison of validators and plugin ecosystems.

Can custom Ajv keywords be used in TypeScript?

Yes. Import KeywordDefinition and SchemaValidateFunction from ajv to type your keyword definition objects and validate functions. For typed error objects, extend ErrorObject from ajv with a narrowed params interface. When using custom keywords alongside JSONSchemaType<T>, cast the schema with as unknown as JSONSchemaType<T> to avoid TypeScript errors about unknown properties. The compile<T>(schema) call still returns a typed guard function. See the Zod schema validation guide if you prefer a TypeScript-first schema library without a separate JSON Schema layer.

How do I validate that values are unique by a specific property with Ajv?

Use the uniqueItemProperties keyword from ajv-keywords: { "type": "array", "uniqueItemProperties": ["id"] }. This rejects any array where two items share the same id value, which the built-in uniqueItems: true keyword cannot do (it uses full deep object equality). For a custom implementation without the plugin, use a compile keyword that builds a property-access function once and checks a Set on each call — this avoids rebuilding the Set structure on every validation. Read the JSON Schema guide for the complete array keyword reference.

Can I make an async custom keyword that queries a database?

Yes. Add async: true to the keyword definition and return a Promise<boolean> from the validate function. The schema must also declare $async: true at the root. Compile it normally with ajv.compile(schema) — Ajv detects the $async flag and returns a Promise-returning validator. Call it with await validateAsync(data) and catch Ajv.ValidationError for validation failures. Keep async keywords focused on single checks and batch any related I/O inside the keyword function to avoid N+1 query patterns during validation.

Are custom Ajv keywords compatible with JSON Schema spec?

Validate and compile keywords are Ajv-specific — schemas using them will fail or be silently ignored in other validators. Macro keywords are the exception: the expanded standard JSON Schema is fully spec-compliant and portable. If you need portability, use macro keywords or keep custom constraints outside the schema (a post-validation step). For schemas shared with external teams or stored as public contracts, stick to the standard vocabulary or document which Ajv-specific keywords are in use and provide fallback validation logic for non-Ajv consumers.

Further reading and primary sources