Arri Validate
A type builder and validation library built on top of the Json Type Definition (RFC 8927) This library is pretty similar to Typebox except that it creates Json Type Definition (JTD) objects instead of Json Schema objects.
A lot of inspiration was taken from both Typebox and Zod when designing this library.
Project Philosophy
The goals of this project are as follows:
- Portable type definitions
- High performance validation, parsing, and serialization
- Consistent error reporting for parsing and serialization errors
I am not looking to support every feature of Typescript's type system or even every possible representation of JSON. The goal is that the data models defined through this library can be used as a source of truth across multiple programming languages. Both JSON and Typescript have to be limited to accomplish this.
Adherence to RFC 8927
To represent the data-models in a language agnostic way this library heavily relies on JSON Type Definition (JTD). However, this library does not strictly comply with the JTD specification. The reason for this is because JTD does not support 64-bit integers. I believe sharing large integers across languages is a huge pain point especially when going to and from Javascript. For this reason alone, I have opted to break away from the JTD spec and add support for int64
and uint64
. So while I have no intention to break further away from the spec I am open to it if a large enough issue arises (in my view). If you use this library be aware that I'm using a superset of JTD rather than a strict spec compliant implementation.
Table of Contents
Installation
npm install arri-validate
pnpm install arri-validate
Basic Example
import { a } from "arri-validate";
const User = a.object({
id: a.string(),
name: a.string(),
});
type User = a.infer<typeof User>;
a.parse(User, `{"id": "1", "name": "John Doe"}`);
a.parse(User, `{"id": "1", "name": null}`);
a.validate(User, { id: "1", name: "John Doe" });
a.validate(User, { id: "1", name: null });
a.serialize(User, { id: "1", name: "John Doe" });
Supported Types
Primitives
Arri Schema | Typescript | Json Type Definition |
---|
a.any() | any | {} |
a.string() | string | { "type": "string" } |
a.boolean() | boolean | {"type": "boolean"} |
a.timestamp() | Date | {"type": "timestamp"} |
a.float32() | number | {"type": "float32"} |
a.float64() | number | {"type": "float64"} |
a.int8() | number | {"type": "int8"} |
a.int16() | number | {"type": "int16"} |
a.int32() | number | {"type": "int32"} |
a.int64() | BigInt | {"type": "int64"} |
a.uint8() | number | {"type": "uint8"} |
a.uint16() | number | {"type": "uint16"} |
a.uint32() | number | {"type": "uint32"} |
a.uint64() | BigInt | {"type": "uint64"} |
Enums
Enum schemas allow you to specify a predefine list of accepted strings
Usage
const Status = a.enumerator(["ACTIVE", "INACTIVE", "UNKNOWN"]);
type Status = a.infer<typeof Status>;
a.validate(Status, "BLAH");
a.validate(Status, "ACTIVE");
Outputted JTD
{
"enum": ["ACTIVE", "INACTIVE", "UNKNOWN"]
}
Arrays / Lists
Usage
const MyList = a.array(a.string());
type MyList = a.infer<typeof MyList>;
a.validate(MyList, [1, 2]);
a.validate(MyList, ["hello", "world"]);
Outputted JTD
{
"elements": {
"type": "string"
}
}
Objects
Usage
const User = a.object({
id: a.string(),
email: a.string(),
created: a.timestamp(),
});
type User = a.infer<typeof User>;
a.validate({
id: "1",
email: "johndoe@example.com",
created: new Date(),
});
a.validate({
id: "1",
email: null,
created: new Date(),
});
Outputted JTD
{
"properties": {
"id": {
"type": "string"
},
"email": {
"type": "string"
},
"created": {
"type": "timestamp"
}
},
"additionalProperties": true
}
Strict Mode
By default arri-validate will ignore and strip out any additional properties when validating objects. If you want validation to fail when additional properties are present then modify the additionalProperties
option.
const UserStrict = a.object(
{
id: a.string(),
name: a.string(),
created: a.timestamp(),
},
{
additionalProperties: false,
},
);
a.parse(UserStrict, {
id: "1",
name: "johndoe",
created: new Date(),
bio: "my name is joe",
});
Outputted JTD
{
"properties": {
"id": {
"type": "string"
},
"email": {
"type": "string"
},
"created": {
"type": "timestamp"
}
},
"additionalProperties": false
}
Records / Maps
Usage
const R = a.record(a.boolean());
type R = a.infer<typeof R>;
a.validate(R, {
hello: true,
world: false,
});
a.validate(R, {
hello: "world",
});
Outputted JTD
{
"values": {
"type": "boolean"
}
}
Discriminated Unions
Usage
const Shape = a.discriminator("type", {
RECTANGLE: a.object({
width: a.float32(),
height: a.float32(),
}),
CIRCLE: a.object({
radius: a.float32(),
}),
});
type Shape = a.infer<typeof Shape>;
type ShapeTypeRectangle = a.inferSubType<Shape, "type", "RECTANGLE">;
type ShapeTypeCircle = a.inferSubType<Shape, "type", "CIRCLE">;
a.validate(Shape, {
type: "RECTANGLE",
width: 1,
height: 1.5,
});
a.validate(Shape, {
type: "CIRCLE",
radius: 5,
});
a.validate(Shape, {
type: "CIRCLE",
width: 1,
height: 1.5,
});
Outputted JTD
{
"discriminator": "type",
"mapping": {
"RECTANGLE": {
"properties": {
"width": {
"type": "float32"
},
"height": {
"type": "float32"
}
},
"additionalProperties": true
},
"CIRCLE": {
"properties": {
"radius": {
"type": "float32"
}
},
"additionalProperties": true
}
}
}
Recursive Types
You can define recursive schemas by using the a.recursive
helper. This function accepts another function that outputs an object schema or a discriminator schema.
An important thing to note is that type inference doesn't work correctly for Recursive schemas. In order to satisfy Typescript you will need to define the type and then pass it to the function as a generic.
Additionally it is recommended to define an ID for any recursive schemas. If one is not specified arri will auto generate one.
If some TS wizard knows how to get type inference to work automatically for these recursive schemas, feel free to open a PR although I fear it will require a major refactor the existing type system.
Usage
type BinaryTree = {
left: BinaryTree | null;
right: BinaryTree | null;
};
const BinaryTree = a.recursive<BinaryTree>(
(self) =>
a.object({
left: a.nullable(self),
right: a.nullable(self),
}),
{
id: "BinaryTree",
},
);
a.validate(BinaryTree, {
left: {
left: null,
right: {
left: null,
right: null,
},
},
right: null,
});
a.validate(BinaryTree, {
left: {
left: null,
right: {
left: true,
right: null,
},
},
right: null,
});
Outputted JTD
{
"properties": {
"left": {
"ref": "BinaryTree",
"nullable": true
},
"right": {
"ref": "BinaryTree",
"nullable": true
}
},
"additionalProperties": true,
"metadata": {
"id": "BinaryTree"
}
}
Modifiers
Optional
Use a.optional()
to make an object field optional.
const User = a.object({
id: a.string(),
email: a.optional(a.string()),
date: a.timestamp();
})
Outputted JTD
{
"properties": {
"id": {
"type": "string"
},
"date": {
"type": "timestamp"
}
},
"optionalProperties": {
"email": {
"type": "string"
}
}
}
Nullable
Use a.nullable()
to make a particular type nullable
const name = a.nullable(a.string());
Outputted JTD
{
"type": "string",
"nullable": true
}
Clone
Copy another schema without copying it's metadata using the a.clone()
helper
const A = a.object(
{
a: a.string(),
b: a.float32(),
},
{ id: "A" },
);
console.log(A.metadata.id);
const B = a.clone(A);
console.log(B.metadata.id);
Extend
Extend an object schema with the a.extend()
helper.
const A = a.object({
a: a.string(),
b: a.float32(),
});
const B = a.object({
c: a.timestamp(),
});
const C = a.extend(A, B);
Omit
Use a.omit()
to create a new object schema with certain properties removed
const A = a.object({
a: a.string(),
b: a.float32(),
});
const B = a.omit(A, ["a"]);
Pick
Use a.pick()
to create a new object schema with the a subset of properties from the parent object
const A = a.object({
a: a.string(),
b: a.float32(),
c: a.timestamp(),
});
const B = a.pick(A, ["a", "c"]);
Partial
Use a.partial()
to create a new object schema that makes all of the properties of the parent schema optional.
const A = a.object({
a: a.string(),
b: a.float32(),
c: a.timestamp(),
});
const B = a.partial(A);
Utilities
Validate
Call a.validate()
to validate an input against an arri schema. This method also acts as a type guard, so any any
or unknown
types that pass validation will automatically gain autocomplete for the validated fields
const User = a.object({
id: a.string(),
name: a.string(),
});
a.validate(User, true);
a.validate(User, { id: "1", name: "john doe" });
if (a.validate(User, someInput)) {
console.log(someInput.id);
}
Parse
Call a.parse()
to parse a JSON string against an arri schema. It will also handle parsing normal objects as well.
const User = a.object({
id: a.string(),
name: a.string(),
});
const result = a.parse(User, jsonString);
Safe Parse
A safer alternative to a.parse()
that doesn't throw an error.
const User = a.object({
id: a.string(),
name: a.string(),
});
const result = a.safeParse(User, jsonString);
if (result.success) {
console.log(result.value);
} else {
console.error(result.error);
}
Coerce
a.coerce()
will attempt to convert inputs to the correct type. If it fails to convert the inputs it will throw a ValidationError
const A = a.object({
a: a.string(),
b: a.boolean(),
c: a.float32(),
});
a.coerce(A, {
a: "1",
b: "true",
c: "500.24",
});
Safe Coerce
a.safeCoerce()
is an alternative to a.coerce()
that doesn't throw.
const A = a.object({
a: a.string(),
b: a.boolean(),
c: a.float32(),
});
const result = a.safeCoerce(A, someInput);
if (result.success) {
console.log(result.value);
} else {
console.error(result.error);
}
Serialize
a.serialize()
will take an input and serialize it to a valid JSON string.
const User = a.object({
id: a.string(),
name: a.string(),
});
a.serialize(User, { id: "1", name: "john doe" });
Be aware that this function does not validate the input. So if you are passing in an any or unknown type into this function it is recommended that you validate it first.
Errors
Use a.errors()
to get all of the validation errors of a given input.
const User = a.object({
id: a.string(),
date: a.timestamp(),
});
a.errors(User, { id: 1, date: "hello world" });
Compiled Validators
arri-validate
comes with a high performance JIT compiler that transforms Arri Schemas into highly optimized validation, parsing, serialization functions.
const User = a.object({
id: a.string(),
email: a.nullable(a.string()),
created: a.timestamp(),
});
const $$User = a.compile(User);
$$User.validate(someInput);
$$User.parse(someJson);
$$User.serialize({ id: "1", email: null, created: new Date() });
In most cases, the compiled validators will be much faster than the standard utilities. However there is some overhead with compiling the schemas so ideally each validator would be compiled once. Additionally the resulting methods make use of eval so they can only be used in an environment that you control such as a backend server. They WILL NOT work in a browser environment.
You can also use a.compile
for code generation. The compiler result gives you access to the generated function bodies.
$$User.compiledCode.validate;
$$User.compiledCode.parse;
$$User.compiledCode.serialize;
Metadata
Metadata is used during cross-language code generation. Arri schemas allow you to specify the following metadata fields:
- id - Will be used as the type name in any arri client generators
- description - Will be added as a description comment above any generated types
- isDeprecated - Will mark any generated code with the deprecation annotation of target language
Examples
A schema with this metadata:
const BookSchema = a.object(
{
title: a.string(),
author: a.string(),
publishDate: a.timestamp(),
},
{
id: "Book",
description: "This is a book",
},
);
will produce types that look something like this during codegen.
Typescript
interface Book {
title: string;
author: string;
publishDate: Date;
}
Rust
struct Book {
title: String,
author: String,
publish_date: DateTime<FixedOffset>
}
Dart
/// This is a book
class Book {
final String title;
final String author;
final DateTime publishDate;
const Book({
required this.title,
required this.author,
required this.publishDate,
});
}
Kotlin
data class Book(
val title: String,
val author: String,
val publishDate: Instant,
)
Benchmarks
Last Updated: 2024-03-19
All benchmarks were run on my personal desktop. You can view the methodology used in ./benchmarks/src.
OS - Pop!_OS 22.04 LTS
CPU - AMD Ryzen 9 5900 12-Core Processor
RAM - 32GB
Graphics - AMD® Radeon rx 6900 xt
Objects
The following data was used in these benchmarks. Relevant schemas were created in each of the mentioned libraries.
{
id: 12345,
role: "moderator",
name: "John Doe",
email: null,
createdAt: 0,
updatedAt: 0,
settings: {
preferredTheme: "system",
allowNotifications: true,
},
recentNotifications: [
{
type: "POST_LIKE",
postId: "1",
userId: "2",
},
{
type: "POST_COMMENT",
postId: "1",
userId: "1",
commentText: "",
},
],
};
Validation
Library | op/s |
---|
Arri (Compiled) | 122,753,978 |
Typebox (Compiled) | 63,131,651 |
Ajv -JTD (Compiled) | 37,814,896 |
Ajv - JTD | 29,402,413 |
Ajv - JSON Schema (Compiled) | 12,003,432 |
Ajv -JSON Schema | 10,057,501 |
Arri | 2,131,823 |
Typebox | 93,5599 |
Zod | 52,1357 |
Parsing
Library | op/s |
---|
JSON.parse | 749,534 |
Arri (Compiled) | 728,382 |
Arri | 378,175 |
Ajv -JTD (Compiled) | 241,107 |
Serialization
Library | op/s |
---|
Arri (Compiled) | 4,272,430 |
Arri (Compiled) Validate and Serialize | 3,846,453 |
Ajv - JTD (Compiled) | 2,012,894 |
JSON.stringify | 938,289 |
Arri | 481,985 |
Coercion
Library | op/s |
---|
Arri | 818,963 |
Zod | 466,092 |
Typebox | 209,363 |
Integers
The following benchmarks measure how quickly each library operates on a single integer value.
Validation
Library | op/s |
---|
Ajv - JSON Schema (Compiled) | 329,332,736 |
Arri (Compiled) | 201,644,167 |
Arri | 186,634,732 |
Ajv - JTD (Compiled) | 151,044,902 |
Typebox (Compiled) | 110,692,029 |
Ajv - JTD | 48,200,004 |
Ajv - JSON Schema | 47,840,571 |
Typebox | 42,363,980 |
Zod | 1,266,268 |
Parsing
Library | op/s |
---|
Arri (Compiled) | 138,189,123 |
Arri | 136,995,619 |
JSON.parse() | 19,911,721 |
Ajv - JTD (Compiled) | 8,996,081 |
Serialization
Library | op/s |
---|
Ajv - JTD (Compiled) | 198,980,679 |
Arri (Compiled) | 190,386,426 |
Arri | 114,799,692 |
JSON.stringify | 21,433,854 |
Coercion
Library | op/s |
---|
Arri | 117,854,219 |
TypeBox | 34,633,126 |
Ajv - JSON Schema | 28,016,735 |
Zod | 1,586,546 |
Development
Building
Run nx build arri-validate
to build the library.
Running unit tests
Run nx test arri-validate
to execute the unit tests via Vitest
Benchmarking
Run nx benchmark arri-validate
to execute benchmarks