Home

Awesome

Jet-Schema ✈️

Simple, zero-dependency, typescript-first schema validation tool, with zero-overhead when it comes to custom validation.

Table of contents

Introduction <a name="introduction"></a>

jet-schema is a simple, TypeScript first schema validation tool, which enables you to use your own validator functions against each property in an object. That way you don't have to refer to documentation everytime you want to validate some new object property. It also means you get to grow a list of TypeScript validator functions that aren't tied to a specific library.

If you're open to jet-schema but think writing your own validator-functions could be a hassle, you can copy-n-paste the file (https://github.com/seanpmaxwell/ts-validators/blob/master/src/validators.ts) into your application and add/remove/edit validators as needed.

Highlights 😎

Quick Glance

import schema from 'utils/schema';
import { isString, isNumber } from 'utils/validators';

interface IUser {
  id: number;
  name: string;
  address: {
    street: string;
    zip: number;
  }
}

const User = schema<IUser>({
  id: isNumber,
  name: isString,
  address: schema({
    street: isString,
    zip: isNumber,
  })
});

User.new({ id: 5, name: 'joe' }) // => { id: 5, name: 'joe' }
User.test('asdf') // => false
User.pick('name').test('john') // => true
User.pick('address').pick('zip').test(234) // => true
User.parse('something') // => Error
<br/>

Comparison to other schema validation libraries <a name="comparison-to-others"></a>

Focus is on using your own list of validator-functions

With most validation-libraries, if we wanted to apply some custom validation, we'd have to refer to the library's documentation and wrap our logic in some handler-function (i.e. zod's .refine). With jet-schema however, anytime we need to add a new property to your schema, you can just drop in a validator-function (see the <a name="what-is-a-validator-function">validator-functions</a> section). Having a list of validator-functions not only makes your schema setups way more terse but also the rest of your code.

Code comparison with zod and jet-schema

interface IUser {
  id: number;
  name: string;
  email: string;
  age: number;
  created: Date;
  address?: {
    street: string;
    zip: number;
    country?: string;
  };
}

// "zod"
const User: z.ZodType<IUser> = z.object({
  id: z.number().default(-1).min(-1),
  name: z.string().default(''),
  email: z.string().email().or(z.literal('')).default('x@x.x'),
  // OR if we had our own "isEmail" validator-function
  email: z.string().refine(val => isEmail(val)).default('x@x.x'),
  age: z.preprocess(Number, z.number()),
  created: z.preprocess((arg => arg === undefined ? new Date() : arg), z.coerce.date()),
  address: z.object({ 
    street: z.string(),
    zip: z.number(),
    country: z.string().optional(),
  }).optional(),
});

// "jet-schema"
const User = schema<IUser>({
  id: isRelationalKey,
  name: isString,
  email: { vf: isEmail, default: 'x@x.x' },
  age: { vf: isNumber, transform: Number },
  created: Date,
  address: schema({
    street: isString,
    zip: isNumber,
    country: isOptionalString,
  }, { optional: true }),
});


// Reusing the validator functions in
// some other part of your application.
if (isString(someVar)) {
  ...do this
} else if (isNumber(someVar)) {
  ...do that
}

Create instances with partials

A major reason I created jet-schema was I needed to create lots of instances of my schemas when testing and copies of existing objects (represented by my schemas) when doing edits. I didn't wanted to have to wrap a parsing function everytime I wanted to create a new instance so I added the .new function (see the <a name="new">.new</a> section for more details).

Size (minified, not zipped)

Fast

Guide <a name="guide"></a>

Installation <a name="installation"></a>

npm install -s jet-schema

What is a validator function? <a name="what-is-a-validator-function"></a>

A validator-function is a TypeScript function which does both runtime AND compile-time validation. The typical way to define one is to give it a signature which receives an unknown value and returns a type-predicate:

function isNullishString(arg: unknown): param is string | undefined | null {
  return arg === undefined || arg === null || typeof arg === 'string';
}

I like to place all my validator-functions in a util/validators.ts file. As mentioned in the intro, you can copy some predefined validators from <a href="https://github.com/seanpmaxwell/ts-validators/blob/master/src/validators.ts">here</a>. One more note, not only does creating a list of validator-functions save boilerplate code, but growing a list of validator-functions not dependent on any library will make them easy to copy-and-paste between multiple projects, saving you a lot of coding time down the line.

Creating schemas <a name="creating-schemas"></a>

▸ Passing validator-functions <a name="passing-validator-functions"></a>

Validator-functions can be passed to schemas directly or within a configuration object. These objects allow us to handle settings for individual validator-functions:

// Validator-function configuration object format:
{
  vf: <T>(arg: unknown) => arg is T; // vf => validator-function 
  default?: T; // the default value for the validator-function
  transform?: (arg: unknown) => T; // modify the value before calling the validator-function
  formatError?: (error: IError) => IError | string; // Customize what's sent to onError() when errors are raised.
}

// Example
const UserSchema = schema({
  name: isString, // Using a validator-function directly
  id: {  // Using a configuration object
    vf: isNumber, // the validator-function in the object
    default: 0,
    transform: Number,
    formatError: err => `Property ${err.property} was not a valid number`,
  },
});

▸ IError object <a name="ierror-object"></a>

In the previous snippet we see the formatError function passes an IError object. The format for an IError object is:

{
  property?: string;
  value?: unknown;
  message?: string;
  location?: string; // function which is throwing the error
  schemaId?: string;
}

▸ The jetSchema() function <a name="the-jet-schema-function"></a>

Schemas can be created by importing the schema function directly from the jet-schema library or importing the default jetSchema function. The jetSchema function can be passed an array of configuration objects and returns a new customized schema function; that way we don't have to configure validator-function settings for every new schema.

The configuration objects are set in the globals: property array. Note that localized settings will overwrite all global ones:

import jetSchema from 'jet-schema';
import { isNum, isStr } from './validators'; 

const schema = jetSchema({
  globals?: [
    { vf: isNum, default: 0 },
    { vf: isStr, default: '' },
  ],
});

const User1 = schema({
  id: isNum,
  name: isStr,
});

const User2 = schema({
  id: { vf: isNum, default: -1 }, // Localized default setting overwriting a global one
  name: isStr,
})

User1.new() // => { id: 0, name: '' }
User2.new() // => { id: -1, name: '' }

▸ jetSchema() additional options <a name="jet-schema-additional-options"></a>

For the jetSchema function, in addition to globals: there are two additional options we can configure:

import jetSchema from 'jet-schema';
import { isNum, isStr } from './validators';

export default jetSchema({
  globals?: [
    { vf: isNum, default: 0 },
    { vf: isStr, default: '' },
  ],
  cloneFn?: (val: unknown) => unknown, // use a custom clone-function
  onError?: (errors: IError[]) => void, // pass a custom error-handler,
});

I usually configure the jetSchema function once per application and place it in a script called utils/schema.ts. From there I import it and use it to configure all individual schemas: take a look at this <a href="https://github.com/seanpmaxwell/express5-typescript-template/tree/master">template</a> for an example.

▸ The schema() function <a name="the-schema-function"></a>

If we did not use the jetSchema function above and instead used the schema function directly, default values would have to be configured everytime. IMPORTANT If your validator-function does not accept undefined as a valid value, you must set a default value because all defaults will be validated at startup:

import { schema } from 'jet-schema';
import { isNum, isStr } from './validators'; 

const User1 = shared({
 id: { vf: isNum, default: 0 },
 name: { vf: isStr, default: '' },
});

const User2 = sharedSchema({
 id: { vf: isNum, default: 0 },
 name: isStr, // ERROR: "isStr" does not accept `undefined` as a valid value but no default was value configured for "isStr"
})

▸ Handling a schema's type <a name="handling-the-schemas-type"></a>

For handling a schema's type, you can enforce a schema from a type or infer a type from a schema.

Option 1: Create a schema using a type:

import { schema } from 'jet-schema';
import { isNum, isStr, isOptionalStr } from 'util/validators.ts';

interface IUser {
  id: number;
  name: string;
  email?: string;
}

const User = schema<IUser>({
  id: isNum,
  name: isStr,
  email: isOptionalStr,
});

Option 2: Create a type using a schema:

import { schema } from 'jet-schema';
import { isNum, isStr, isOptionalStr } from 'util/validators.ts';

const User = schema({
  id: isNum,
  name: isStr,
  email: isOptionalStr,
});

const TUser = inferType<typeof User>;

▸ The "options" param <a name="schema-options"></a>

In addition to an object with our schema's properties, the schema function accepts an additional options parameter:

const User = schema<IUser>({
  id: isNum,
  name: isStr,
}, /* { ...options object... } */); // <-- Pass options here

options explained:

options example:

type TUser = IUser | null | undefined;

const User = schema<TUser>({
  id: isNum,
  name: isStr,
}, {
  optional: true, // Must be true because TUser is `| undefined`
  nullable: true, // Must be true because TUser is `| null`
  nullish: true, // Alternative to { optional: true, nullable: true }
  init: false, // Can be "null", "false", or "true"
  id: 'User',
  safety: 'strict'
});

Schema APIs <a name="schema-apis"></a>

Once you have your custom schema setup, you can call the .new, .test, .pick, and .parse functions.

NOTE: the following examples assume you set 0 as the default for isNum, '' for isStr, nothing for isOptionalStr, and safety is left at its default filter option. See the <a name="creating-schemas">Creating Schemas</a> section for how to set default values and the safety option.

.new <a name="new"></a>

Allows you to create new instances of your type using partials. If the property is absent, .new will use the default supplied. If no default is supplied and the property is optional, then the value will be skipped. Runtime validation will still be done on every incoming property:

User.new(); // => { id: 0, name: '' }
User.new({ id: 5 }); // => { id: 5, name: '' }
User.new({ id: 'asdf' }); // => Error
User.new({ name: 'john' }); // => { id: 0, name: 'john' }
User.new({ id: 1, name: 'a', email: 'b@b' }); // => { id: 1, name: 'a', email: 'b@b' }

.test <a name="test"></a>

Accepts any unknown value, tests that it's valid, and returns a type-predicate:

User.test(); // => Error
User.test({ id: 5, name: 'john' }); // => param is IUser
User.test({ name: 'john' }); // => Error
User.test({ id: 1, name: 'a', email: 'b@b' }); // => param is IUser

.pick <a name="pick"></a>

Selects a property and returns an object with the .test and .default functions. If you use .pick on a child schema, you can also use the schema functions (.new, .pick etc), in addition to .default. Note that for a child-schema, .default could return a different value from .new if the default value is set to null or undefined (see the init: setting in the <a href="#schema-options">Schema Options</a> section).

const User = schema<IUser>({
  id: isNum,
  address: schema<IUser['address']>({
    street: isStr,
    city: isStr,
  }, { init: null }),
});

User.pick('id').default(); // => "0"
User.pick('id').test(0); // => "true"
User.pick('id').test('asdf'); // => "false"
User.pick('address').new(); // => { street: '', city: '' }
User.pick('address').default(); // => "null"
User.pick('address').pick('city').test('asdf'); // => "true"

.parse <a name="parse"></a>

Like a combination of .new and .test. It accepts an unknown value which is not optional, validates the properties but returns a new instance (while removing an extra ones) instead of a type-predicate. Note: only objects will pass the .parse function, even if a schema is nullish, null/undefined values will not pass.

const User = schema<IUser>({
  id: isNum,
  name: isStr,
});

User.parse(); // => Error
User.parse({ id: 1, name: 'john' }); // => { id: 1, name: 'john' }
User.parse({ id: 1, name: 'john', foo: 'bar' }); // => { id: 1, name: 'john' }
User.parse({ id: '1', name: 'john' }); // => Error

Combining Schemas <a name="combining-schemas"></a>

If you want to declare part of a schema that will be used elsewhere, you can import the TJetSchema type and use it to setup a partial schema, then merge it with your full schema later:

import schema, { TJetSchema } from 'jet-schema';
import { isNumber, isString, isBoolean } from './validators';

const PartOfASchema: TJetSchema<{ id: number, name: string }> = {
  id: isNumber,
  name: isString,
} as const;

const FullSchema = schema<{ id: number, name: string, e: boolean }>({
  ...PartOfASchema,
  e: isBoolean,
});

console.log(FullSchema.new());

TypeScript Caveats <a name="typescript-caveats"></a>

Due to how structural-typing works in typescript, there are some limitations with typesafety that you need to be aware of. To put things in perspective, if type A has all the properties of type B, we can use type A for places where type B is required, even if A has additional properties.

▸ Validator functions

If an object property's type can be string | undefined, then a validator-function whose type-predicate only returns param is string will still work. However a if a type predicate returns param is string | undefined we cannot use it for type string. This could cause runtime issues if a you pass a validator function like isString (when you should have passed isOptionalString) to a property whose value ends up being undefined:

interface IUser {
  id: string;
  name?: string;
}

const User = schema<IUser>({
  id: isString, // "isOptionalString" will throw type errors
  name: isOptionalString, // "isString" will not throw type errors but will throw runtime errors
})

▸ Child schemas

As mentioned, if a property in a parent-schema is a mapped-object type (it has a defined set of keys), then you need to call schema again for the nested object. If you don't use a generic on the child-schema, typescript will still make sure all the required properties are there; however, because of structural-typing the child could have additional properties. It is highly-recommended that you pass a generic to your child-objects so additional properties don't get added:

interface IUser {
  id: number;
  address?: { street: string } | null;
}

const User = schema<IUser>({
  id: isNumber,
  address: schema<IUser['address']>({
    street: isString,
    // foo: isString, // If we left off the generic <IUser['address']> we could add "foo"
  }, { nullish: true }),
});

If you know of a way to enforce typesafety on child-object without requiring a generic please make a pull-request because I couldn't figure out a way.

Bonus Features <a name="bonus-features"></a>

Using jet-schema without TypeScript <a name="without-typescript"></a>

jet-schema is built in TypeScript for TypScript but can be used directly with plain JavaScript. There are two minified files you can import if you want to use plain javascript:

Tips <a name="tips"></a>

Creating wrapper functions <a name="creating-wrapper-functions"></a>

If you need to modify the value of the .test function for a property, (like removing nullables) then I recommended merging your schema with a new object and adding a wrapper function around that property's test function.

// models/User.ts
import { nonNullable } from 'util/validators.ts';

interface IUser {
  id: number;
  address?: { street: string, zip: number } | null;
}

const User = schema<IUser>({
  id: isNumber,
  address: schema<IUser['address']>({
    street: isString,
    zip: isNumber,
  }, { nullish: true }),
})

export default {
  // Wrapper function to remove nullables
  checkAddr: nonNullable(User.pick('address').test),
  ...User,
}

Recommended Globals Settings <a name="recommended-global-settings"></a>

I highly recommend you set these default values for each of your basic primitive validator-functions, unless of course your application has some other specific need:

import { isNum, isStr, isBool } from 'util/validators.ts';

export default jetSchema({
  globals: [
    { vf: isNum, default: 0 },
    { vf: isStr, default: '' },
    { vf: isBool, default: false },
  ],
});

Combining jet-schema with parse() from ts-validators <a name="parse-from-ts-validators"></a>

The before mentioned repo <a href="https://github.com/seanpmaxwell/ts-validators/blob/master">ts-validators</a> contains a function called parse (not to be confused with the jet-schema function .parse) which is handy for doing lots of little validations on objects where setting up a full stand-alone schema isn't really practical:

import { Request, Response } from 'express';
import { parse, isNum } from 'util/validators.ts'; // standalone .parse function
import User from 'models/User.ts' // This was setup with jet-schema

const validateReqBody = parse({
  userId: isNum,
  address: User.pick('address').test,
})

/**
 * parse checks req.body and makes sure .userId is a number with the isNum
 * validator-function and that the .address is a valid User['address'] object 
 * using the "User" schema setup by jet-schema.
 */
function updateUsersAddr(req: Request, res: Response) {
  const { userId, address } = validateReqBody(req.body);
  ...do stuff
}

See this <a href="https://github.com/seanpmaxwell/express5-typescript-template/tree/master/src/routes">template</a> for a full example.

<br/>