Migrating from ajv

Your JSON Schemas stay as they are: schiva reads draft-04, draft-06, draft-07, 2019-09 and 2020-12, and passes the whole JSON-Schema-Test-Suite of each. What changes is the code around them: how you compile, what the function returns and how errors look. This page goes through all of it, with the few differences that can change a result.

Three ways to move

schiva is not a drop-in replacement: the schemas are the same, the API is not. Pick the path that fits:

If...Do this
You call ajv.compile() in a few placesMove directly: replace it with compileJsonSchema() and read the errors it returns. Start with Side by side and follow the checklist.
Much code reads validate.errors and instancePathUse the ajv-compatible adapter (about 40 lines to copy): the same compile(), validate.errors and error objects, then move the code over little by little.
You use FastifySet schiva as its validator compiler, with Fastify's own defaults.

In every case, run both libraries on your schemas and test data first: it takes a few lines and shows whether anything behaves differently for you.

Why move

Install

npm install schiva
npm uninstall ajv ajv-formats ajv-keywords ajv-draft-04   # once nothing uses them
ajv importschiva
import Ajv from 'ajv' (draft-07)import { compileJsonSchema } from 'schiva', or const { compileJsonSchema } = require('schiva'). One function for every draft.
import Ajv2019 from 'ajv/dist/2019'
import Ajv2020 from 'ajv/dist/2020'
import Ajv from 'ajv-draft-04'
import addFormats from 'ajv-formats'the option formats
import ajvKeywords from 'ajv-keywords'import { ajvKeywords } from 'schiva'
import standaloneCode from 'ajv/dist/standalone'import { standaloneJsonSchema } from 'schiva'
import type { JSONSchemaType, ErrorObject } from 'ajv'import type { JsonSchema, ErrorObject } from 'schiva' (see TypeScript)

What changes

Side by side

const Ajv2020 = require('ajv/dist/2020');
const addFormats = require('ajv-formats');

const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
ajv.addSchema(addressSchema, 'https://example.com/address.json');

const validate = ajv.compile(orderSchema);

app.post('/orders', (req, res) => {
  if (!validate(req.body)) {
    return res.status(400).json({ errors: validate.errors });
  }
  // ...
});

Replacing the instance

An Ajv instance does three things: it holds shared options and schemas, it caches compiled functions, and it finds schemas by $id. A few lines of your own do the same:

// validation.js: shared by the whole application
const { compileJsonSchema } = require('schiva');

const options = { draft: '2020-12', formats: true, schemas: [addressSchema, productSchema] };

// Like ajv.compile(): compiled once per schema object, then reused.
const cache = new WeakMap();
function compile(schema) {
  if (!cache.has(schema)) {
    cache.set(schema, compileJsonSchema(schema, options));
  }
  return cache.get(schema);
}

// Like ajv.getSchema(id): a schema that only references the one with that "$id".
const getSchema = (id) => compileJsonSchema({ $ref: id }, options);

module.exports = { compile, getSchema };

The cache matters if your code called ajv.compile() or ajv.validate() on each request and relied on ajv's cache: compileJsonSchema() compiles every time it is called. Compile when the program starts, or cache as above.

API

ajvschiva
new Ajv(options), Ajv2019, Ajv2020, ajv-draft-04no instance; the options go to each compileJsonSchema(); the draft comes from $schema, or the option draft
ajv.compile(schema)compileJsonSchema(schema, options)
validate(data) → true/falsevalidate(data) → true/false, compiled with errors: false
validate.errors (null when valid)what the function returns: [] when valid
ajv.errorsText(validate.errors)errors.join(', '): messages already name the field
ajv.validate(schema, data)compile once and call the function (see Replacing the instance)
ajv.compileAsync(schema) with loadSchemacompileJsonSchemaAsync(schema, { loadSchema }) (see Several documents)
ajv.addSchema(schema, uri)the option schemas: { uri: schema }, or [schema] with $id
ajv.getSchema(id)compileJsonSchema({ $ref: id }, { schemas })
ajv.removeSchema(), ajv.addUsedSchemanothing is registered globally: pass the schemas you want to each call
ajv.validateSchema(schema), ajv.addMetaSchema()compile the meta-schema, registered in schemas (see below)
ajv.addFormat(name, format), ajv-formatsthe option formats (see Formats)
ajv.addKeyword('x-internal') (an annotation)the option keywords: ['x-internal']
ajv.addKeyword() with validate, compile or macroa definition in the option keywords, with the same three kinds (see Keywords of your own)
ajv.addVocabulary([...])keywords: [...]
ajv-keywords (require('ajv-keywords')(ajv))keywords: ajvKeywords(), with the same results, except transform, dynamicDefaults and select
standaloneCode(ajv, validate)standaloneJsonSchema(schema, options) (see Standalone code)

ajv checks every schema against its meta-schema when compiling (validateSchema: true). schiva checks keywords and types (an unknown keyword or { "type": 5 } throws), but not every value against the meta-schema: { "minimum": "1" } compiles. To check schemas as ajv did, for example in a test, use the meta-schema that ajv ships:

const metaSchema = require('ajv/dist/refs/json-schema-draft-07.json');
const validateSchema = compileJsonSchema({ $ref: 'http://json-schema.org/draft-07/schema#' }, { schemas: [metaSchema] });
validateSchema({ type: 5 }); // ['type must be one of: array, boolean, ...', ...]

Options

ajv optionschiva
allErrors: truethe default; allErrors: false for the first error only (ajv's default)
strict, strictSchemastrict: true by default, an unknown keyword throws; strict: false ignores it. There is no 'log': nothing is logged.
strictTypesa keyword the type excludes ({ "type": "string", "minimum": 1 }) throws; with strict: false it is ignored, as the standard says
strictTuples, strictRequiredno such checks; required keys that properties does not declare are checked as usual
validateSchema, metasee above: keywords and types are checked, values are not
schemas, addUsedSchemaschemas, for each call
validateFormats, ajv-formatsformats
useDefaults, removeAdditional, coerceTypesthe same options, with the same results (see Changing the data and Differences)
multipleOfPrecisionmultipleOfPrecision, the same
discriminator: truealways on, with ajv's rules and OpenAPI's mapping and implicit names too (see Discriminator)
loadSchemaan option of compileJsonSchemaAsync()
ownPropertiesalways: only own properties count (ajv reads inherited ones by default)
unicode (default true)always: string lengths count code points
verbose, messageserrors always have their message; objects have params too
code: { source: true }, code.esmstandaloneJsonSchema(), with format: 'esm'
$datanot supported (see below)
passContextnot needed: keyword functions receive (value, data, parentSchema)
logger, uriResolver, inlineRefs, loopRequired, loopEnum, code.optimize, code.linesnot needed: schiva logs nothing, resolves URIs itself and chooses how to generate code
int32range, parseDate, timestamp (JTD)no JTD, so not needed

Differences in results

On valid schemas, schiva and ajv agree on which values are valid, except in these cases. Most come from ajv and schiva reading the standard differently where ajv chose to depart from it; check the ones that apply to you.

CaseajvschivaWhat to do
Schema without $schemathe draft of the class (Ajv2020: 2020-12)draft-07pass draft
format without the formats optionstrict mode throws: unknown formatnot checked, as the standard allowspass formats: true (as you had ajv-formats)
A keyword the type excludeswarns (strictTypes)throwsfix the schema, or strict: false
Inherited properties (Object.create(proto), class instances)count as presentdo not countvalidate plain data, such as parsed JSON
coerceTypes inside anyOf / oneOfconverts inside each subschema, even ones that then do not applyconverts by the schema's own type onlylist the types in one type: ["number", "boolean"]
default inside anyOf, oneOf, not, if with useDefaultsignoredthrows (ignored with strict: false)move the default to a schema that always applies
discriminator with mappingrejectedsupported, as in OpenAPInothing
discriminator with unevaluatedPropertieswrong results: properties of the picked schema count as unevaluatedcorrectnothing
2019-09 / 2020-12: $dynamicRef, $recursiveRef, some unevaluated* casesfails some tests of the suitepasses themnothing, unless you relied on ajv's result

Errors

By default a schiva function returns messages that start with the path of the field, ready for logs and HTTP responses: 'lines[0].price must be at least 0'. For code that reads the errors, as with validate.errors, use errors: 'objects':

const validate = compileJsonSchema(schema, { errors: 'objects' });
validate({ lines: [{ price: -1 }] });
// [{ path: ['lines', 0, 'price'], pointer: '/lines/0/price', keyword: 'minimum', params: { limit: 0 },
//    message: 'lines[0].price must be at least 0' }]
ajv errorschiva error object
instancePath ('/lines/0/price')pointer, the same JSON Pointer; and path, as an array (['lines', 0, 'price'])
keywordkeyword, with the same names, and nullable for a null the type does not allow
paramsparams: limit, allowedValues, allowedValue, pattern, format, type, multipleOf as in ajv; see the differences below
message ('must be >= 0')message, with the field in front: 'lines[0].price must be at least 0' (see Messages)
schemaPathnot given
required: at the object, with params.missingPropertyrequired: at the path of the missing property
dependentRequired: at the objectat the path of the missing property, with params.property and params.missingProperty
additionalProperties: at the object, with params.additionalPropertyat the path of the unexpected key, with params.property
unevaluatedProperties: at the object, with params.unevaluatedPropertyat the path of the unexpected key, with params.property
propertyNames: the error of the check, then one with params.propertyName, both at the objectonly the error of the key's check, at the key's path, with propertyName: true

Other differences, for code that reads the errors closely:

Messages

If tests or code compare messages, they change. The messages of the most common keywords, for a property a:

Keywordajv (with instancePath /a)schiva
typemust be stringa must be a string
requiredmust have required property 'a' (at the object)a is mandatory
additionalPropertiesmust NOT have additional properties (at the object)Unexpected key: a
minimum / maximummust be >= 0 / must be <= 10a must be at least 0 / a must be at most 10
exclusiveMinimummust be > 0a must be greater than 0
multipleOfmust be multiple of 5a must be a multiple of 5
minLength / maxLengthmust NOT have fewer than 3 charactersa must be at least 3 characters long
patternmust match pattern "^[a-z]+$"a does not match the required pattern
formatmust match format "email"a must be a valid email
enummust be equal to one of the allowed valuesa must be one of: x, y
constmust be equal to constanta must be equal to x
minItems / maxItemsmust NOT have fewer than 2 itemsa must have at least 2 elements
uniqueItemsmust NOT have duplicate items (items ## 0 and 1 are identical)a must not have duplicate elements
minPropertiesmust NOT have fewer than 1 propertiesa must have at least 1 properties
dependentRequiredmust have property b when property a is presentb is mandatory when a is present
oneOfmust match exactly one schema in oneOfa must match exactly one schema, but matches more than one
notmust NOT be valida must not match the excluded schema
propertyNamesproperty name must be validKey abc must be at most 2 characters long

The value itself is called Value: Value must be a string. For messages of your own, see Custom messages.

Formats

schiva checks the formats itself, without a plugin:

ajvschiva
addFormats(ajv){ formats: true }
addFormats(ajv, ['date', 'email']){ formats: ['date', 'email'] }
ajv.addFormat('phone', /^\+\d+$/){ formats: { phone: /^\+\d+$/ } }
ajv.addFormat('even', { validate: (s) => ... }){ formats: { even: (s) => ... } }
ajv.addFormat('version', { validate, compare }){ formats: { version: { validate, compare } } }
formatMinimum, formatMaximum and the exclusive ones (ajv-formats)the same keywords, with the same results (see Formats)
addFormats(ajv, { mode: 'fast' })one mode: the checks follow the RFCs and pass every format test of the suite
ajv.addFormat('int32', true) (known, not checked){ formats: { ...builtInFormats(), int32: false } }
unknown format: throws in strict modethe same: a format the option does not name throws, unless strict: false

schiva also checks idn-hostname and idn-email. Following the RFCs, it can disagree with ajv-formats on unusual values: it accepts an email with a quoted local part ("john doe"@example.com), which ajv rejects, and rejects 2026-02-30, which ajv's fast mode accepts. Run your test data through both if formats matter to you.

Several documents

Documents that $ref points to go in the option schemas, by URI or with their $id. Relative ids work as in ajv and Fastify: a document with "$id": "address" is reached by "$ref": "address#". To load documents as ajv's compileAsync() does, use compileJsonSchemaAsync() with the same loadSchema function: it is called once for each document the schema reaches and schemas does not have.

compileJsonSchema(orderSchema, { schemas: [addressSchema, countrySchema] }); // schemas with "$id"
await compileJsonSchemaAsync(orderSchema, { loadSchema }); // loads them

A reference that does not resolve throws when compiling. Each document is read in the draft its own $schema names, so a 2020-12 schema can reference draft-07 documents.

An ajv-compatible adapter

When much code reads validate.errors, this adapter gives it what it expects while the validation runs on schiva. Copy it into your project, replace new Ajv(options) with createAjvLike(options), and move the code to schiva's own API when convenient.

// ajv-like.js: ajv's compile(), validate.errors and error objects, on schiva.
const { compileJsonSchema } = require('schiva');

// Errors that ajv reports at the object, with the key in params.
const AT_OBJECT = {
  required: 'missingProperty',
  dependentRequired: 'missingProperty',
  additionalProperties: 'additionalProperty',
  unevaluatedProperties: 'unevaluatedProperty',
};
const COMPARISONS = { minimum: '>=', maximum: '<=', exclusiveMinimum: '>', exclusiveMaximum: '<' };

function toAjvError(error) {
  const parent = error.pointer.slice(0, error.pointer.lastIndexOf('/'));
  const key = error.path[error.path.length - 1];
  const base = { schemaPath: '', keyword: error.keyword, message: error.message };
  if (AT_OBJECT[error.keyword]) {
    const params = error.keyword === 'dependentRequired' ? error.params : {};
    return { ...base, instancePath: parent, params: { ...params, [AT_OBJECT[error.keyword]]: key } };
  }
  if (error.propertyName) {
    return { ...base, instancePath: parent, keyword: 'propertyNames', params: { propertyName: key } };
  }
  const comparison = COMPARISONS[error.keyword];
  return { ...base, instancePath: error.pointer, params: comparison ? { comparison, ...error.params } : error.params };
}

// Like new Ajv(options), with schiva's options (formats, schemas, draft...).
function createAjvLike(options = {}) {
  const cache = new WeakMap();
  // ajv stops at the first error unless allErrors: true.
  const compileOptions = { ...options, allErrors: options.allErrors === true, errors: 'objects' };
  return {
    compile(schema) {
      if (!cache.has(schema)) {
        const check = compileJsonSchema(schema, compileOptions);
        const validate = (data) => {
          const errors = check(data);
          validate.errors = errors.length === 0 ? null : errors.map(toAjvError);
          return errors.length === 0;
        };
        cache.set(schema, validate);
      }
      return cache.get(schema);
    },
    errorsText(errors) {
      return errors ? errors.map((error) => error.message).join(', ') : 'No errors';
    },
  };
}

module.exports = { createAjvLike };

instancePath, keyword and params then match ajv's for the common keywords (schemaPath is empty); the messages are schiva's, and the remaining differences are listed in Errors.

Comparing both

Before switching, validate your test data (or a sample of production data) with both libraries and look at the values where they disagree. It takes a few lines, for example in a test:

const Ajv2020 = require('ajv/dist/2020');
const addFormats = require('ajv-formats');
const { compileJsonSchema } = require('schiva');

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

function compareOn(schema, values) {
  const withAjv = ajv.compile(schema);
  const withSchiva = compileJsonSchema(schema, { draft: '2020-12', formats: true, errors: false });
  return values.filter((value) => withAjv(value) !== withSchiva(value));
}

// [] when both agree on every value.
console.log(compareOn(orderSchema, orderSamples));

With useDefaults, removeAdditional or coerceTypes, give each library its own copy of each value (structuredClone(value)), as both change it.

Fastify

Fastify validates with ajv, with the options coerceTypes: 'array', useDefaults: true, removeAdditional: true and allErrors: false. schiva has the same options, so a validator compiler keeps the behavior of your routes:

const Fastify = require('fastify');
const { compileJsonSchema } = require('schiva');

// The schemas you gave to fastify.addSchema(), which a custom compiler does not see.
const shared = [{ $id: 'address', type: 'object', required: ['city'], properties: { city: { type: 'string' } } }];

const app = Fastify();
app.setValidatorCompiler(({ schema }) => {
  const validate = compileJsonSchema(schema, {
    schemas: shared,
    // Fastify's defaults with ajv:
    coerceTypes: 'array',
    useDefaults: true,
    removeAdditional: true,
    allErrors: false,
  });
  return (data) => {
    const errors = validate(data);
    return errors.length === 0 ? { value: data } : { error: new Error(errors.join(', ')) };
  };
});

app.post('/orders', {
  schema: {
    body: {
      type: 'object',
      required: ['qty', 'address'],
      properties: { qty: { type: 'integer', minimum: 1 }, address: { $ref: 'address#' } },
    },
  },
}, async (req) => req.body);
// POST { "qty": 0, "address": {} } -> 400 { "message": "qty must be at least 1", ... }

Response schemas are serialized by fast-json-stringify, not validated, so they are not affected. Add formats: true if your schemas use format, as Fastify includes ajv-formats.

OpenAPI

OpenAPI 3.1 schemas are JSON Schema 2020-12: pass draft: '2020-12'. OpenAPI 3.0 schemas are an older dialect, which reads like draft-04 (exclusiveMinimum is a boolean) with a few more keywords:

const validate = compileJsonSchema(openApi30Schema, {
  draft: 'draft-04',
  // OpenAPI's formats that are not checked, besides the built-in ones.
  formats: { ...builtInFormats(), int32: false, int64: false, float: false, double: false, byte: false, binary: false, password: false },
  // OpenAPI's annotations, and your extensions: or strict: false to ignore every unknown keyword.
  keywords: ['example', 'xml', 'externalDocs', 'x-internal'],
});

Custom messages

ajv-errors (errorMessage) and ajv-i18n change messages. With schiva, build them from the keyword and params of error objects, in one place:

const validate = compileJsonSchema(schema, { errors: 'objects' });

const MESSAGES = {
  required: () => 'es obligatorio',
  minimum: (params) => `debe ser como mínimo ${params.limit}`,
  format: (params) => `no es un ${params.format} válido`,
};

function messagesOf(errors) {
  return errors.map((error) => {
    const message = MESSAGES[error.keyword];
    return message ? `${error.path.join('.')} ${message(error.params)}` : error.message;
  });
}

messagesOf(validate({ age: 3 })); // ['name es obligatorio', 'age debe ser como mínimo 18']

For a message per field, as errorMessage gives, key the table by error.pointer instead. Keep errorMessage out of the schemas (it throws as an unknown keyword), or declare it with keywords: ['errorMessage'] to leave it as an annotation.

Standalone code

standaloneJsonSchema(schema, options) gives the source of a module to write to a file when building, as ajv's standaloneCode() and ajv-cli do. There is no instance to create, the options are those of compileJsonSchema() with format: 'commonjs' or 'esm', and the module needs nothing else, not even schiva:

// build-validators.js, run when building (in place of ajv-cli compile)
const fs = require('fs');
const { standaloneJsonSchema } = require('schiva');

fs.writeFileSync('validate-order.mjs', standaloneJsonSchema(orderSchema, { formats: true, format: 'esm' }));

See Standalone code for several validators in one module.

TypeScript

ajvschiva
ajv.compile<T>(schema): a type guardcompileJsonSchema<T>(schema, { errors: false }): a type guard for T
JSONSchemaType<T>: checks a schema against a typeno counterpart; with the DSL, the type comes from the schema instead: type Order = Infer<typeof order>
ErrorObjectErrorObject (path, pointer, keyword, params, message)
AnySchema, SchemaObjectJsonSchema, JsonSchemaObject
OptionsJsonSchemaOptions & CompileOptions

What schiva does not do

ajv featureIn schiva
$data referencesNot supported: $data as a keyword throws. Careful: inside the value of const or enum, { "$data": "1/b" } is not rejected but read as a plain value, as the JSON Schema standard says. Check those comparisons in your code.
Keywords that write generated code (addKeyword with code)Not supported: define them with validate, compile or macro.
ajv-keywords transform, dynamicDefaults, selectNot supported: the first two change the data when validating, and select needs $data. Transform or fill the data before validating.
Asynchronous validation ($async schemas)$async throws (ignored with strict: false): validation is synchronous. Loading documents is supported, with compileJsonSchemaAsync(). Run asynchronous checks (a database lookup) after validating.
JSON Type Definition (JTD, ajv/dist/jtd)Not supported.
ajv-errors (errorMessage), ajv-i18nBuild messages from error objects (see Custom messages).
ajv-merge-patch ($merge, $patch)Not supported: merge the schemas in your code before compiling, or use allOf.
schemaPath in errorsNot given; errors have the path of the value, the keyword and its params.
Checking schemas against the meta-schema when compilingKeywords and types are checked, values are not; compile the meta-schema to check them (see API).

Checklist

  1. Run both libraries on your schemas and test data, and look at any disagreement.
  2. Replace ajv.compile(schema) with compileJsonSchema(schema, options), compiled once, when the program starts (or cached, see Replacing the instance).
  3. If you used Ajv2019 or Ajv2020 with schemas without $schema, pass draft.
  4. Move addSchema() documents to the option schemas, ajv-formats to formats: true, and ajv-keywords to keywords: ajvKeywords().
  5. Pick the result: messages (default), errors: 'objects' in place of validate.errors, or errors: false for true/false.
  6. If you relied on ajv stopping at the first error, add allErrors: false.
  7. Compile every schema once: an unknown keyword, or one the type excludes, throws and shows what to change (or use strict: false).
  8. Look for $data, $async, errorMessage and transform, which need changes; useDefaults, removeAdditional and coerceTypes are the same options.
  9. Where error objects are read, map instancePath to pointer, and required and additionalProperties errors to their new paths, or use the adapter.
  10. Update tests that compare messages (see Messages).
  11. Uninstall ajv and its plugins.