validation-first schema library with a functional api

Overview

zap

zap is a validation-first schema library with a functional Api.

Some major features are

  • Flexible refinement and validation API
  • Transformation, Coercion and type narrowing
  • JSONSchema support

Quick Start

Install

npm install @spaceteams/zap
or
yarn add @spaceteams/zap

then import functions directly like this

import { number, object } from "@spaceteams/zap";
const mySchema = object({ a: number() });

Schema Definitions and Type Inference

To get started you only need to know a few schema types and some utility functions. Have a look at this user schema

const user = object({
  name: string(),
  dateOfBirth: optional(date()),
});
type User = InferType<typeof user>;

This defines a schema user that has a required name and an optional date of birth.

We also infer the type of the Schema which is equivalent to

type User = {
  name: string;
  dateOfBirth?: Date | undefined;
};

Typeguards and Validations

This schema can now be used as a type guard

function processUser(value: unknown): string {
  if (user.accepts(value)) {
    // typescript infers value to be of type User
    return value.name;
  }
  // in this branch value is of unknown type!
  return "not a user!";
}

Or if you need full validation errors

const validationErrors = user.validate({ name: 12 });
console.log(translate(validationErrors));

will print out

{ name: 'value was of type number expected string' }

For the sake of demonstration we use translate() to make the validation object more readable. Actually, name is not a string but an object containing more information about the validation error such as an issue code, the actual value validated against, etc...

Parsing and Coercion

The schema type also comes with a parse function. This function builds a new object that conforms to the schema. By default however, the schema won't be able to convert types. For example

user.parse({ name: "Joe", dateOfBirth: "1999-01-01" });

will throw a validation error because "1999-01-01" is a string and not a Date object. You can fix this problem with coercion like this

const coercedDate = coerce(date(), (v) => {
  if (typeof v === "string" || typeof v === "number") {
    return new Date(v);
  }
  return v;
});
const user = object({
  name: string(),
  dateOfBirth: optional(coercedDate),
});

The coerce function applies the Date only if the value is a string or a number and does not touch any other value (including dates).

The usecase of coercion from string to Date is so common that we have coercedDate() as a builtin function

Table of Contents

🚧 A lot of chapters of the documention are still missing. But each chapter links to relevant source code and specs.

Core

Schema

spec and source

At the core of zap is the schema interface. All schema functions (like object(), number(), string()...) return an object that implements it. It is defined as

export interface Schema<I, O = I, M = { type: string }> {
  accepts: (v: unknown, options?: Partial<ValidationOptions>) => v is I;
  validate: (
    v: unknown,
    options?: Partial<ValidationOptions>
  ) => ValidationResult<I>;
  validateAsync: (
    v: unknown,
    options?: Partial<ValidationOptions>
  ) => Promise<ValidationResult<I>>;
  parse: (v: unknown, options?: Partial<Options>) => ParseResult<I, O>;
  parseAsync: (
    v: unknown,
    options?: Partial<Options>
  ) => Promise<ParseResult<I, O>>;
  meta: () => M;
}

which is quite a handful.

Let us start with accepts and validate. Both get a value of unknown type and run validations on it. While validate builds a complete ValidationResult containing all found validation errors, accepts only returns a typeguard and is slightly more efficient thatn validate. The type of this validation is the first generic type I. If you don't care for the other two generic types you can write such a schema as Schema<I>. Both functions also accept a set of options. Tey currently include earlyExit (default false) which will stop validation on the first issue and withCoercion (default false) which will also coerce values on Validation (see coerce)

There is an async version of both validate and parse available. These are needed if you use async refinements.

Validation is great if you want to check if a value conforms to a schema, but sometimes you want to coerce, tranform a value or strip an object of additional fields. For these cases you want to call parse(). This function returns a Result of type

type ParseResult<I, O> = {
  parsedValue?: O;
  validation?: ValidationResult<I>;
};

parsedValue is defined if parsing was successful, otherwise validation contains the validation issues found. Note that parse has two generic types: I and O. The first is the type the Schema accepts. The second one is O the output type. By default it is equal to I but can be changed with transform() or narrow() (see transform and narrow). Like validate it accepts options, so you can configure the validation step and also ParsingOptions that control the parsing behaviour. There is strip (default true) that will remove all additional properties from objects and skipValidation (default false) if you do not want to validate, but directly run the parse step.

The last method defined is meta(). It returns an object that describes the schema. For example items(array(number()), 10).meta() will return an object of type

{
  type: "array";
  schema: Schema<number, number, { type: "number"; }>;
} & {
  minItems: number;
  maxItems: number;
}

You can use this object to traverse the schema tree (via the schema attribute, that is present because array contains another schema) or reflect on validation rules (for example minItems is set to 10 in the example). This object is used heavily in utility functions like toJsonSchema() or partial().

To make traversing the meta object tree easier we have Optics

Validation

spec and source

Let us have a closer look at the ValidationResult. The type is defined as

export type ValidationResult<T, E = ValidationIssue> =
  | Validation<T, E>
  | undefined;

So it is dependent on T and an error type E. By default the error type is a ValidationIssue. This class extends Error so it can be thrown nicely and it contains a ValidationIssueCode, a custom error message, the validated value and a list of args to give further information about the validation. Using the translate method you can transform a ValidationResult<T, ValidationIssue> into a ValidationResult<T, string> containing user readable validation errors. The default translator is a bit technical though, so it is up to you to translate ValidationIssues for your users.

The ValidationResult can now either by undefined indicating a success or a Validation<T, E> indicating a failure. You can check this with isSuccess and isFailure functions. The ValidationResult has a pretty complicated typedefinition but it tries to resamble a deeply partial T with ValidationIssues instead of the actual types. Consider this validation:

type Value = {
  array: number[];
  nested: {
    id: string;
  };
};
const validation: Validation<Value, string> = {
  array: [undefined, "validation error"],
  nested: "object invalid",
};

This is a validation of type Validation<Value, string> so it validates Value and uses a string to describe validation issues. In the example the second entry of array has a validation error and the nested object itself has a validation error.

By default zap will keep on validation even if an issue has been encountered (you can change this with the earlyExit flag). We even keep on validating through an and schema (aka Intersection type) and merge the individual Validation objects. This is especially helpful when validating complex forms.

Refine

spec and source

Out of the box zap supports a lot of validation methods. Methods like length for strings or after for dates. These validation methods (or refinements) are described together with their applicable schemas.

You can build custom validation methods, however. And the simplest way is the validIf function

validIf(number(), (v) => v % 2 === 0, "must be even");

This function creates a validation error if the given number is not even.

The next powerful refinement function is just called refine. It takes a schema and a function (v: I, ctx: RefineContext<P>) => void | ValidationResult<P>. Where the ctx object contains both the ValidationOptions and helper methods add and validIf. The generic Parameter P is defined as P extends I = I, which means that it is I by default or it narrows it further.

Refine supports three styles of refinement:

const schema = object({ a: string(), b: number() });
const defaultStyle = refine(schema, ({ a, b }) => {
  if (a.length !== b) {
    return {
      a: new ValidationIssue("generic", "a must have length of b", v),
    };
  }
});
const builderStyle = refine(schema, ({ a, b }, { add }) => {
  if (a.length !== b) {
    add({
      a: new ValidationIssue("generic", "a must have length of b", v),
    });
  }
});
const inlineStyle = refine(schema, ({ a, b }, { validIf }) => ({
  a: validIf(a.length === b, "a must have length of b"),
}));

Here we refine an object {a: string, b: number} so that the string a has length b. In the first style the ValidationResult itself is returned. This is very similar to the refine method the Schema supports. The second style is using the add method. This approach is useful if you want to iteratively collect validation errors and have them merged into a final validation result. And finally, there is an inline style using the validIf method. The advantage of refine over the simpler validIf is that you can add validation errors anywhere in the ValidationResult. For exmple you could validate the age field and write the error inside the name field. Also you can do narrowing:

refine(
  object({ id: optional(string()) }),
  (v, ctx: RefineContext<{ id: string }>) => ({
    id: ctx.validIf(v !== undefined, "must be present"),
  })
);

which will result in a type {id: string} and not {id?: string | undefined}.

Most of zap's built-in validation functions are implemented using refineWithMetainformation. They add meta-information that can be picked up and interpreted by utility functions like toJsonSchema

There are also validIfAsync, refineAsync and refineAsyncWithMetaInformation. Consider validation of a user registration

// call the backend
const userAvailable = (_username: string) => Promise.resolve(true);

export const userRegistration = object({
  username: validIfAsync(
    string(),
    userAvailable,
    "this username is already taken"
  ),
});

This will call the function userAvailable if userName is a string and await the result. You should of course consider to debounce, deduplicate and cache your requests to the backend depending on your usecase. To use this schema you have to call validateAsync and refineAsync, the synchronous versions will result in validation errors.

Coerce

spec and source

By default, a schema will not try to convert values during the parse step. In that case, the parse function will return its inputs without changing them. If you want to parse values like "1998-10-05" as dates however, you will need coercion.

coerce takes a schema and a function (v: unknown) => unknown that may or may not convert the given value. Currently, this function is applied during parse before the validation step and again for the actual parsing. Coercion is not applied in accepts or validate so a coercedDate() will still accept only dates (it is a Schema<Date> after all!). You can override this behaviour using the withCoercion option.

The predefined coerced schemas are

coercedBoolean,
coercedDate,
coercedNumber,
coercedString

They are implemented using the default coercion of javascript. Note that this comes with all the pitfalls and weirdnesses of javascript. For example [] is coerced to 0, '' or true with to coercedNumber, coercedString and coercedBoolean respectively.

Transform and Narrow

spec and source

After you parsed a value, you might want to further transform it. For example the schema defaultValue(optional(number()), 42) will parse undefined to 42. This schema has type Schema<number | undefined, number> indicating that it will still accept undefined but will always parse to a number.

The defaultValue function is implemented using narrow(). This function takes a schema and a projection function (v: O) => P where P extends O. This means that the narrowed type must still be assignable to the ouput type.

If you need even more powerful transformations you can use transform(). This function takes a schema and an arbitrary transformation (v: O) => P. This is very similar to narrow() except for the missing contraint on P. With this function you can implement a schema like this

transform(array(number()), values => Math.max(...values))

This schema accepts an array of numbers and parses them into their maximum value. This schema has a type like Schema<number[], number>.

Simple Schema Types

BigInt

spec and source

bigInt() accepts BigInt values.

There is a coercedBigInt that uses standard JS coercion using the BigInt constructor.

Most of the Number refinements also work for bigInt.

Boolean

spec and source

boolean() accepts boolean values. It is equivalent to literals(true, false) but creates slightly more precise validation issues.

There is a coercedBoolean that uses standard JS coercion to boolean.

Date

spec and source

date() validates Date objects and accepts only if they point to an actual time by validating them against isNaN.

There is a coercedDate that uses the Date constructor if the value is string or number.

Validation Functions

before - accept dates before the given value
after - accept dates after the given value

Enum

spec and source

nativeEnum validates native typescript enum types (not to be confused with a union of literals).

Defining a schema

enum E {
  a,
  b = 12,
  c = "c",
}
const schema = nativeEnum(E);

results in a type Schema<E> that accepts the enum values E.a through E.c or their actual values 0, 12 and "c".

You can also define a nativeEnum from a constant object

const constEnum = nativeEnum({
  a: "a",
  b: 12,
  c: "c",
} as const);

resulting in a type Schema<"a" | "c" | 12>.

Literal

spec and source

Number

spec and source

String

spec and source

Composite Schema Types

Array

spec and source

Map

spec and source

Object

spec and source

Procedure

spec and source

Promise

spec and source

Record

spec and source

Set

spec and source

Tuple

spec and source

Logic

And

spec and source

Not

spec and source

Or

spec and source

XOR

spec and source

Utility

Lazy

spec and source

Optics

spec and source

If you want to access a schema in a nested structure like this

const schema = object({
  a: object({
    b: object({
      c: number(),
    }),
  }),
  moreFields: number(),
});

you can use the meta object:

schema.meta().schema.a.meta().schema.b.meta().schema.c;

this is feels cumbersome and a bit hard to read so we built a function get

get(get(get(schema, "a"), "b"), "c");

With this function you can also reach into Maps, Records, Tuples and Procedures. For composite schemas that only have one nested schema like Array, Set and Promise we have into. For example

into(array(string()));

will return the string() schema.

These functions are inspired by lenses and functional references but are unfortunately not as powerful. They are directly applied onto a schema, you are only able to reach down one level and you cannot mutate schemas. So not at all optics, but it is the spirit that counts!

If you want to mutate an existing schema you could do that together with and and omit

and(omit(schema, "a"), get(get(schema, "a"), "b"));

this replaces the field a by c resulting in a Schema<{ c: number; moreFields: number(); }>

Optional, Required, Nullable & Nullish

Optional: spec and source

Partial & DeepPartial

Partial: spec and source DeepPartial: spec and source

ToJsonSchema

spec and source

You might also like...

A functional, immutable, type safe and simple dependency injection library inspired by angular.

func-di English | 简体中文 A functional, immutable, type safe and simple dependency injection library inspired by Angular. Why func-di Installation Usage

Dec 11, 2022

A robust form library for Lit that enriches input components with easy-to-use data validation features.

A robust form library for Lit that enriches input components with easy-to-use data validation features.

EliteForms A robust form library for Lit that enriches input components with easy-to-use data validation features. Installation npm install elite-form

Jun 28, 2022

Javascript Library providing form validation helpers

Javascript-Form-Validation Javascript Library providing form validation helpers Table of contents Installation Usage Include Library Use components Co

Mar 25, 2022

✅Validation library for ES6+ projects

validatees Validation package for ES6+, TypeScript and JavaScript(CommonJS and Module) ready. Features 🚀 Easy to use: Easy to install in your project

Dec 27, 2022

Renders and SVG schema of SARS-CoV-2 clade as defined by Neststrain

ncov-clade-schema https://ncov-clades-schema.vercel.app/ Visualizes current tree of SARS-CoV-2 clades. Allows to generate an SVG image of this tree. C

Nov 3, 2022

Validate directory structure and file contents with an extension of JSON schema.

directory-schema-validator Description Validate directory structure and file contents with an extension of JSON schema. Install Install using NPM or s

Nov 1, 2022

Create a C# .NET core EntityFramework ORM from your schema.prisma file

A note of forewarning to the would-be user... This was a failure. I'm making a note here: huge regret. It's hard to overstate my dissatisfaction. 🍰 S

Dec 24, 2022

typescript-to-jsonschema generates JSON Schema files from your Typescript sources.

fast-typescript-to-jsonschema English | 简体中文 a tool generate json schema from typescript. Feature compile Typescript to get all type information conve

Nov 28, 2022

Prisma +2 generator to emit Yup schemas from your Prisma schema

Prisma +2 generator to emit Yup schemas from your Prisma schema

Prisma Yup Generator Automatically generate Yup schemas from your Prisma Schema, and use them to validate your API endpoints or any other use you have

Dec 24, 2022
Comments
  • Support all js primitives

    Support all js primitives

    We nearly have all js primitives. Number, bigint (added in #1), string, undefined and null. there is only symbol missing. So add that and move undefined and null to simple/ folder.

    enhancement good first issue 
    opened by dvallin 0
  • Specialized regex refinements

    Specialized regex refinements

    We have regex(), but specialized and well tested regexes could be great. Like

    • uuid
    • Email
    • url
    • cuid
    • ip
    • URL

    the jsonschema spec has a lot more Examples

    enhancement good first issue 
    opened by dvallin 0
Owner
Spaceteams GmbH
Spaceteams GmbH
A simple CLI to generate a starter schema for keystone-6 from a pre-existing prisma schema.

Prisma2Keystone A tool for converting prisma schema to keystone schema typescript This is a proof of concept. More work is needed Usage npx prisma2key

Brook Mezgebu 17 Dec 17, 2022
RenderIf is a function that receives a validation as a parameter, and if that validation is true, the content passed as children will be displayed. Try it!

RenderIf RenderIf is a function that receives a validation as a parameter, and if that validation is true, the content passed as children will be disp

Oscar Cornejo Aguila 6 Jul 12, 2022
⚡ the first open-source redis client made with care and acessibility-first 🚀

⚡ Redis UI The first open-source project to create an awesome and accessible UI for Redis as a native desktop application. ✨ ?? ?? How to develop loca

Nicolas Lopes Aquino 14 Dec 5, 2022
Functional-style Cloudflare Durable Objects with direct API calls from Cloudflare Workers and TypeScript support.

durable-apis Simplifies usage of Cloudflare Durable Objects, allowing a functional programming style or class style, lightweight object definitions, a

Dabble 12 Jan 2, 2023
A totally functional user api. It's a service where you list users, create users, update or even delete them.

USER-API ?? ABOUT A user api system made with TypeScript using express and prisma. It's a service where you list user, create users, update them or ev

Luiz Sanches 4 Oct 27, 2022
A base project for Express with Typescript to create an API. Includes automatic input validation and Swagger UI generation.

(Typescript) Express API with input Validation and Swagger UI Thats a mouthful isn't it. Typescript: The language used, a superset of Javascript with

Tjeerd Bakker 6 Oct 26, 2022
Composition API & Yup Powered Form Validation

vue-yup-form Composition API & Yup Powered Form Validation. This tiny library allows Vue and Yup to be a best friend. Requirements The following versi

Masaki Koyanagi 12 Dec 26, 2022
Functional reactive UI library

reflex Reflex is a functional reactive UI library that is heavily inspired by (pretty much is a port of) elm and it's amazingly simple yet powerful ar

Mozilla 364 Oct 31, 2022
Typescript library for functional programming.

Sa Lambda Typescript library for functional programming. Document TODO Either Maybe Iterator Pipe & Flow Task (Promise-Like) some math utils Installat

SoraLib 9 Dec 6, 2022
A functional library for watermarking images in the browser

A functional library for watermarking images in the browser. Written with ES6, and made available to current browsers via Babel. Supports urls, file inputs, blobs, and on-page images.

Brian Scaturro 1.7k Dec 27, 2022