- ajv-ts
- Table of Contents
- Zod unsupported APIs/differences
- Installation
- Basic usage
- JSON schema overriding
- Defaults
- Primitives
- Constant values(literals)
- String
- Numbers
- BigInts
- NaNs
- Dates
- Enums
- Native enums
- Optionals
- Nullables
- Objects
- Arrays
- Tuples
- unions/or
- Intersections/and
- Set
- Map
any
/unknown
never
not
/exclude
- Custom Ajv instance
custom
shema definition- Transformations
- Error handling
JSON schema builder like in ZOD-like API
TypeScript schema validation with static type inference!
Reasons to install ajv-ts
instead of zod
- Less code.
zod
has 4k+ lines of code - not JSON-schema compatibility out of box (but you can install some additional plugins)
- we not use own parser, just
ajv
, which wild spreadable(90M week installations forajv
vs 5M forzod
) - Same typescript types and API
- You can inject own
ajv
instance!
We inspired API from zod
. So you just can reimport you api and that's it!
s.date
,s.symbol
,s.void
,s.void
,s.bigint
,s.function
does not supported. Since JSON-schema doesn't defineDate
,Symbol
,void
,function
,Set
,Map
as separate type. For strings you can uses.string().format('date-time')
or other JSON-string format compatibility: https://json-schema.org/understanding-json-schema/reference/string.htmls.null
===s.undefined
- same types, but helps typescript with autocompletionz.enum
andz.nativeEnum
it's a same ass.enum
. We make enums fully compatible, it can be array of strings or structure defined withenum
keyword in typescript- Exporting
s
isntead ofz
, sinces
- is a shorthand forschema
z.custom
is not supportedz.literal
===s.const
.
npm install ajv-ts # npm
yarn add ajv-ts # yarn
bun add ajv-ts # bun
pnpm add ajv-ts # pnpm
Creating a simple string schema
import { s } from "ajv-ts";
// creating a schema for strings
const mySchema = s.string();
// parsing
mySchema.parse("tuna"); // => "tuna"
mySchema.parse(12); // => throws Ajv Error
// "safe" parsing (doesn't throw error if validation fails)
mySchema.safeParse("tuna"); // => { success: true; data: "tuna" }
mySchema.safeParse(12); // => { success: false; error: AjvError }
Creating an object schema
import { s } from "ajv-ts";
const User = s.object({
username: s.string(),
});
User.parse({ username: "Ludwig" });
// extract the inferred type
type User = s.infer<typeof User>;
// { username: string }
In case of you have alredy defined JSON-schema, you create an any/object/number/string/boolean/null
schema and set schema
property from your schema.
Example:
import s from 'ajv-ts'
const SchemaFromSomewhere = {
"title": "Example Schema",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"age": {
"description": "Age in years",
"type": "integer",
"minimum": 0
},
},
"required": ["name", "age"]
}
type MySchema = {
name: string;
age: number
}
const AnySchema = s.any()
AnySchema.schema = SchemaFromSomewhere
AnySchema.parse({name: 'hello', age: 18}) // OK, since we override JSON-schema
// or using object
const Obj = s.object<MySchema>()
Obj.schema = SchemaFromSomewhere
Obj.parse({name: 'hello', age: 18}) // OK
Option default
keywords throws exception during schema compilation when used in:
- not in properties or items subschemas
- in schemas inside anyOf, oneOf and not (#42)
- in if schema
- in schemas generated by user-defined macro keywords.
This means only object()
and array()
buidlers are supported.
Example
import s from 'ajv-ts'
const Person = s.object({
age: s.int().default(18)
})
Person.parse({}) // { age: 18 }
import { s } from "ajv-ts";
// primitive values
s.string();
s.number();
s.boolean();
// empty types
s.undefined();
s.null();
// allows any value
s.any();
s.unknown();
const tuna = s.const("tuna");
const twelve = s.const(12);
const tru = s.const(true);
// retrieve literal value
tuna.value; // "tuna"
includes a handful of string-specific validations.
// validations
s.string().maxLength(5);
s.string().minLength(5);
s.string().length(5);
s.string().format('email');
s.string().format('url');
s.string().regex(regex);
s.string().format('date-time');
s.string().format('ipv4');
// transformations
s.string().postprocess(v => v.trim());
s.string().postprocess(v => v.toLowerCase());
s.string().postprocess(v => v.toUpperCase());
includes a handful of number-specific validations.
s.number().gt(5);
s.number().gte(5); // alias .min(5)
s.number().lt(5);
s.number().lte(5); // alias .max(5)
s.number().int(); // value must be an integer
s.number().positive(); // > 0
s.number().nonnegative(); // >= 0
s.number().negative(); // < 0
s.number().nonpositive(); // <= 0
s.number().multipleOf(5); // Evenly divisible by 5. Alias .step(5)
Not supported
Not supported
Not supported, but you can pass parseDates
in your AJV instance.
const FishEnum = s.enum(["Salmon", "Tuna", "Trout"]);
type FishEnum = s.infer<typeof FishEnum>;
// 'Salmon' | 'Tuna' | 'Trout'
const VALUES = ["Salmon", "Tuna", "Trout"] as const;
const FishEnum = s.enum(VALUES);
To get autocompletion with a enum, use the .enum
property of your schema:
FishEnum.enum.Salmon; // => autocompletes
FishEnum.enum;
/*
=> {
Salmon: "Salmon",
Tuna: "Tuna",
Trout: "Trout",
}
*/
You can also retrieve the list of options as a tuple with the .options property:
FishEnum.options; // ["Salmon", "Tuna", "Trout"];
Numeric enums:
enum Fruits {
Apple,
Banana,
}
const FruitEnum = s.enum(Fruits);
type FruitEnum = s.infer<typeof FruitEnum>; // Fruits
FruitEnum.parse(Fruits.Apple); // passes
FruitEnum.parse(Fruits.Banana); // passes
FruitEnum.parse(0); // passes
FruitEnum.parse(1); // passes
FruitEnum.parse(3); // fails
String enums:
enum Fruits {
Apple = "apple",
Banana = "banana",
Cantaloupe, // you can mix numerical and string enums
}
const FruitEnum = s.enum(Fruits);
type FruitEnum = s.infer<typeof FruitEnum>; // Fruits
FruitEnum.parse(Fruits.Apple); // passes
FruitEnum.parse(Fruits.Cantaloupe); // passes
FruitEnum.parse("apple"); // passes
FruitEnum.parse("banana"); // passes
FruitEnum.parse(0); // passes
FruitEnum.parse("Cantaloupe"); // pass
Const enums:
The .enum()
function works for as const objects as well.
const Fruits = {
Apple: "apple",
Banana: "banana",
Cantaloupe: 3,
} as const;
const FruitEnum = s.enum(Fruits);
type FruitEnum = s.infer<typeof FruitEnum>; // "apple" | "banana" | 3
FruitEnum.parse("apple"); // passes
FruitEnum.parse("banana"); // passes
FruitEnum.parse(3); // passes
FruitEnum.parse("Cantaloupe"); // fails
You can access the underlying object with the .enum property:
FruitEnum.enum.Apple; // "apple"
You can make any schema optional with s.optional()
. This wraps the schema in a Optional
instance and returns the result.
const schema = s.string().optional();
schema.parse(undefined); // => returns undefined
type A = s.infer<typeof schema>; // string | undefined
const nullableString = s.string().nullable();
nullableString.parse("asdf"); // => "asdf"
nullableString.parse(null); // => null
nullableString.parse(undefined); // throws error
// all properties are required by default
const Dog = s.object({
name: s.string(),
age: s.number(),
});
// extract the inferred type like this
type Dog = s.infer<typeof Dog>;
// equivalent to:
type Dog = {
name: string;
age: number;
};
Use .keyof
to create a Enum
schema from the keys of an object schema.
const keySchema = Dog.keyof();
keySchema; // Enum<["name", "age"]>
You can add additional fields to an object schema with the .extend
method.
const DogWithBreed = Dog.extend({
breed: s.string(),
});
You can use .extend
to overwrite fields! Be careful with this power!
Equivalent to A.extend(B.schema)
.
const BaseTeacher = s.object({ students: s.array(s.string()) });
const HasID = s.object({ id: s.string() });
const Teacher = BaseTeacher.merge(HasID);
type Teacher = s.infer<typeof Teacher>; // => { students: string[], id: string }
Inspired by TypeScript's built-in Pick
and Omit
utility types, all object schemas have .pick
and .omit
methods that return a modified version. Consider this Recipe schema:
const Recipe = s.object({
id: s.string(),
name: s.string(),
ingredients: s.array(s.string()),
});
To only keep certain keys, use .pick .
const JustTheName = Recipe.pick({ name: true });
type JustTheName = s.infer<typeof JustTheName>;
// => { name: string }
To remove certain keys, use .omit
.
const NoIDRecipe = Recipe.omit({ id: true });
type NoIDRecipe = s.infer<typeof NoIDRecipe>;
// => { name: string, ingredients: string[] }
Inspired by the built-in TypeScript utility type Partial
, the .partial method makes all properties optional.
Starting from this object:
const user = s.object({
email: s.string(),
username: s.string(),
});
// { email: string; username: string }
We can create a partial version:
const partialUser = user.partial();
// { email?: string | undefined; username?: string | undefined }
You can also specify which properties to make optional:
const optionalEmail = user.partial({
email: true,
});
/*
{
email?: string | undefined;
username: string
}
*/
Contrary to the .partial
method, the .required
method makes all properties required.
Starting from this object:
const user = z
.object({
email: s.string(),
username: s.string(),
})
.partial();
// { email?: string | undefined; username?: string | undefined }
We can create a required version:
const requiredUser = user.required();
// { email: string; username: string }
You can also specify which properties to make required:
const requiredEmail = user.required({
email: true,
});
/*
{
email: string;
username?: string | undefined;
}
*/
Accepts keys which are required. Set requiredProperties
for your JSON-schema
const O = s.object({
first: s.number().optional(),
second: s.string().optional()
}).requiredFor('first')
type O = s.infer<typeof O> // {first: number, second?: string}
Accepts keys which are partial. unset properties from required
schema field in your JSON-schema
const O = s.object({
first: s.number().optional(),
second: s.string().optional()
}).required().partialFor('second')
type O = s.infer<typeof O> // {first: number, second?: string}
By default object schemas strip out unrecognized keys during parsing.
const person = s.object({
name: s.string(),
});
person.parse({
name: "bob dylan",
extraKey: 61,
});
// => { name: "bob dylan" }
// extraKey has been stripped
Instead, if you want to pass through unknown keys, use .passthrough()
.
person.passthrough().parse({
name: "bob dylan",
extraKey: 61,
});
// => { name: "bob dylan", extraKey: 61 }
By default JSON object schemas allow to pass unrecognized keys during parsing. You can disallow unknown keys with .strict()
. If there are any unknown keys in the input - will throw an error.
const person = z
.object({
name: s.string(),
})
.strict();
person.parse({
name: "bob dylan",
extraKey: 61,
});
// => throws ZodError
The dependentRequired
keyword conditionally requires that
certain properties must be present if a given property is
present in an object. For example, suppose we have a schema
representing a customer. If you have their "credit card number",
you also want to ensure you have a "billing address".
If you don't have their credit card number, a "billing address"
operty
on another using the dependentRequired
keyword.
The value of the dependentRequired
keyword is an object.
Each entry in the object maps from the name of a property, p,
to an array of strings listing properties that are required
if p is present.
const Test1 = s.object({
name: s.string(),
credit_card: s.number(),
billing_address: s.string(),
}).requiredFor('name').dependentRequired({
credit_card: ['billing_address'],
})
/**
Test1.schema === {
"type": "object",
"properties": {
"name": { "type": "string" },
"credit_card": { "type": "number" },
"billing_address": { "type": "string" }
},
"required": ["name"],
"dependentRequired": {
"credit_card": ["billing_address"]
}
}
*/
The additionalProperties
keyword is used to control the handling of extra stuff, that is, properties
whose names are
not listed in the properties
keyword or match any of the regular expressions in the patternProperties
keyword.
By default any additional properties are allowed.
If you need to set additionalProperties=false
use strict
method
const Test = s.object({
street_name: s.string(),
street_type: s.enum(["Street", "Avenue", "Boulevard"])
}).rest(s.string())
Test.schema === {
"type": "object",
"properties": {
"street_name": { "type": "string" },
"street_type": { "enum": ["Street", "Avenue", "Boulevard"] }
},
"additionalProperties": { "type": "string" }
}
const stringArray = s.array(s.string());
type StringArray = s.infer<typeof stringArray> // string[]
Or it's invariant
const stringArray = s.string().array();
type StringArray = s.infer<typeof stringArray> // string[]
Or you can pass empty schema
const empty = s.array()
type Empty = s.infer<typeof empty> // unknown[]
push(append) schema to array(parent) schema.
Example:
import s from 'ajv-ts'
const empty = s.array()
const stringArr = empty.addItems(s.string())
stringArr.schema // {type: 'array', items: [{ type: 'string' }]}
Use .element
to access the schema for an element of the array.
stringArray.element; // => string schema, not array schema
If you want to ensure that an array contains at least one element, use .nonempty()
.
const nonEmptyStrings = s.array(s.string()).nonempty();
// the inferred type is now
// [string, ...string[]]
nonEmptyStrings.parse([]); // throws: "Array cannot be empty"
nonEmptyStrings.parse(["Ariana Grande"]); // passes
s.string().array().min(5); // must contain 5 or more items
s.string().array().max(5); // must contain 5 or fewer items
s.string().array().length(5); // must contain 5 items exactly
Unlike .nonempty()
these methods do not change the inferred type.
Set the uniqueItems
keyword to true
.
const UniqueNumbers = s.array(s.number()).unique()
UniqueNumbers.parse([1,2,3,4]) // Ok
UniqueNumbers.parse([1,2,3,3]) // Error
Unlike arrays, tuples have a fixed number of elements and each element can have a different type.
const athleteSchema = s.tuple([
s.string(), // name
s.number(), // jersey number
s.object({
pointsScored: s.number(),
}), // statistics
]);
type Athlete = s.infer<typeof athleteSchema>;
// type Athlete = [string, number, { pointsScored: number }]
A variadic ("rest") argument can be added with the .rest method.
const variadicTuple = s.tuple([s.string()]).rest(s.number());
const result = variadicTuple.parse(["hello", 1, 2, 3]);
// => [string, ...number[]];
includes a built-in s.union method for composing "OR" types.
This function accepts array of schemas by spread argument.
const stringOrNumber = s.union(s.string(), s.number());
stringOrNumber.parse("foo"); // passes
stringOrNumber.parse(14); // passes
Or it's invariant - or
function:
s.number().or(s.string()) // number | string
Intersections are "logical AND" types. This is useful for intersecting two object types.
const Person = s.object({
name: s.string(),
});
const Employee = s.object({
role: s.string(),
});
const EmployedPerson = s.intersection(Person, Employee);
// equivalent to:
const EmployedPerson = Person.and(Employee);
// equivalent to:
const EmployedPerson = and(Person, Employee);
Though in many cases, it is recommended to use A.merge(B)
to merge two objects. The .merge
method returns a new Object instance, whereas A.and(B)
returns a less useful Intersection instance that lacks common object methods like pick
and omit
.
const a = s.union(s.number(), s.string());
const b = s.union(s.number(), s.boolean());
const c = s.intersection(a, b);
type c = s.infer<typeof c>; // => number
Not supported
Not supported
Any and unknown defines {}
(empty object) as JSON-schema. very useful if you need to create something specific
Never defines using {not: {}}
(empty not). Any given json schema will be fails.
Here is a 2 differences between not
and exclude
.
not
method wrap given schema withnot
exclude(schema)
- addnot
keyword for incomingschema
argument
Example:
import s from 'ajv-ts'
// not
const notAString = s.string().not() // or s.not(s.string())
notAString.valid('random string') // false, this is a string
notAString.valid(123) // true
// exclude
const notJohn = s.string().exclude(s.const('John'))
notJohn.valid('random string') // true
notJohn.valid('John') // false, this is John
// advanced usage
const str = s.string<'John' | 'Mary'>().exclude(s.const('John'))
s.infer<typeof str> // 'Mary'
If you need to create a custom AJV Instance, you can use create
or new
function.
import addKeywords from 'ajv-keywords';
import schemaBuilder from 'ajv-ts'
const myAjvInstance = new Ajv({parseDate: true})
export const s = schemaBuilder.create(myAjvInstance)
// later:
s.string().dateTime().parse(new Date()) // 2023-10-05T19:31:57.610Z
If you need to append something specific to you schema, you can use custom
method.
const condition = s.any() // schema: {}
const withIf = condition.custom('if', {properties: {foo: {type: 'string'}}})
withIf.schema // {if: {properties: {foo: {type: 'string'}}}}
function thant will be applied before calling parse
method, It can helps you to modify incomining data
Be careful with this information
const ToString = s.string().preprocess(x => {
if(x instanceof Date){
return x.toISOString()
}
return x
}, s.string())
ToString.parse(12) // error: expects a string
ToString.parse(new Date()) // 2023-09-26T13:44:46.497Z
function thant will be applied after calling parse
method.
const ToString = s.number().postprocess(x => String(x), s.string())
ToString.parse(12) // after parse we get "12" 12 => "12".
ToString.parse({}) // error: expects number. Postprocess has not been called
Defines custom error message for any error. Error message from ajv-error
package
Set schema.errorMessage = message
.
Inspired from zod
. Set custom validation. Any result exept undefined
will throws(or exposed for safeParse
method).
import s from 'ajv-ts'
// example: object with only 1 "active element"
const Schema = s.object({
active: s.boolean(),
name: s.string()
}).array().refine((arr) => {
const subArr = arr.filter(el => el.active === true)
if (subArr.length > 1) throw new Error('Array should contains only 1 "active" element')
})
Schema.parse([{ active: true, name: 'some 1' }, { active: true, name: 'some 2' }]) // throws Error