Simple, zero-dependency, typescript-first schema validation tool, with zero-overhead when it comes to custom validation.
- Introduction
- Comparison to other schema validation libraries
- Guide
- Tips
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.
If you're open to
jet-schemabut think writing your own validator-functions could be a hassle, you install its sister library jet-validators which includes some predefined validator-functions.
- Focus on using your own validator-functions to validate object properties.
- Enable extracting logic for nested schemas.
- Create new instances of your schemas using partials.
- Quickly learn this terse, small library that only exports 2 functions and 3 types, size 4.7kB minified.
- Doesn't require a compilation step (so still works with
ts-node, unliketypia). - Fast! see these benchmarks.
- Typesafety works boths ways, you can infer a type from a schema or force a schema to have certain properties using a generic.
- Works client-side or server-side.
- TypeScript first!
- Sample project using jet-schema here.
import schema, { inferType } from 'utils/schema';
import { isString, isNumber } from 'utils/validators';
const User = schema({
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
type TUser = inferType<User>; // Get the typeA 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';
}When validating an individual object's property, there's literally an infinite list of validations that can be done which are specific to that application needs (i.e. different businesses might have different requirements for an email format). So I thought, why not just strip all that away and just make something that allows you to use existing validator-functions to check an object's properties?
Other reasons to keep your own list of validator-functions:
- Reuse your validators in multiple parts of your code across multiple projects without worrying about which library they are tied to.
- Reduce the need to repeatedly wrap your specialized logic in a library's handlers (i.e. zod's
.refinefunction). - Name your validator-functions however you want (for example: I like to use abbreviations a lot).
- Make your code substantially more terse.
- Setup your schema and never refer to the library's documentation again.
jet-schematakes a fire-and-forget approach - As mentioned in the intro, you can skip having to defined some of the more common ones by installing jet-validators.
import { isString, isNumber, isRelationalKey, isEmail, isOptionalString } from 'your-validators.ts';
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 }),
});- Jet-Schema 5kB
- Zod 57kB
- Yup 40kB
- Joi 150kB
- Valibot 35kB
- Note: some of these could change as packages are updated
- See these benchmarks here.
- Looking at the benchmarks in the link above, compare jet-schema to some other popular validators and notice that it is rougly 2-3 times as fast as the ones which don't require a compilation setup (i.e. zod, valibot, yup etc).
Another major reason I created jet-schema was to create multiple instances of my schemas using partials and copies of existing instances when doing edits. This can be done with the .new function (see the .new section for more details).
npm install -s jet-schema
Validator-functions can be passed to schemas directly or within a configuration-object. These objects allow us to handle settings for individual validator-functions:
// 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`,
},
});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;
}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. Note that localized settings will overwrite all global ones:
import jetSchema from 'jet-schema';
import { isNumber, isString } from './validators';
const schema = jetSchema({
globals?: [
{ vf: isNumber, default: 0 },
{ vf: isString, default: '' },
],
});
const User1 = schema({
id: isNumber,
name: isString,
});
const User2 = schema({
id: { vf: isNumber, default: -1 }, // Localized default setting overwriting a global one
name: isString,
});
User1.new(); // => { id: 0, name: '' }
User2.new(); // => { id: -1, name: '' }For the jetSchema function, in addition to globals: there are two additional options we can configure:
cloneFn: A custom clone-function. When using the.newfunction, all partial values will be cloned. The default clone function usesstructuredClone(I like to uselodash.cloneDeep).onError: Configure what happens when errors are raised. By default, a javascriptnew Error()is thrown with the array of errors stringified in the error message.
import jetSchema from 'jet-schema';
import { isNumber, isString } from './validators';
export default jetSchema({
globals?: [
{ vf: isNumber, default: 0 },
{ vf: isString, default: '' },
],
cloneFn?: (val: unknown) => unknown, // use a custom clone-function
onError?: (errors: IError[]) => void, // pass a custom error-handler,
});I usually configure the
jetSchemafunction once per application and place it in a script calledutils/schema.ts. From there I import it and use it to configure all individual schemas: take a look at this template for an example.
If we did not use the jetSchema function above and instead used the schema function directly, default values would have to be reconfigured 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 { isNumber, isString } from './validators';
const User1 = schema({
id: { vf: isNumber, default: 0 },
name: { vf: isString, default: '' },
});
const User2 = schema({
id: { vf: isNumber, default: 0 },
name: isString, // ERROR: "isString" does not accept `undefined` as a valid value but no default value was configured for "isString"
});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 { isNumber, isString, isOptionalString } from 'util/validators.ts';
interface IUser {
id: number;
name: string;
email?: string;
}
const User = schema<IUser>({
id: isNumber,
name: isString,
email: isOptionalString,
});Option 2: Create a type using a schema:
import { schema, inferType } from 'jet-schema';
import { isNumber, isString, isOptionalString } from 'util/validators.ts';
const User = schema({
id: isNumber,
name: isString,
email: isOptionalString,
});
const TUser = inferType<typeof User>;In addition to an object with our schema's properties, the schema function accepts an additional options parameter:
const User = schema<IUser>({
id: isNumber,
name: isString,
}, /* { ...options object... } */); // <-- Pass options hereoptions explained:
optional: Defaultfalse, must be set to true if the generic is optional.nullable: Defaultfalse, must be set to true if the generic is nullable.nullish: Defaultfalse, convenient alternative to{ optional: true, nullable: true; }init: Tells the parent what to do when the parent calls.new.false: Skip creating a child-object. The child-object must beoptional.true: Create a new child-object (Uses the child's.newfunction).null: Set the child object's value tonull(nullablemust be true for the child).
id: A unique-identifier for the schema passed to theIErrorobject.safety: Sets how to deal with additional properties.'filter' (default): Properties not in the schema will be filtered out but not raise errors.'pass': Properties not in the schema will not be filtered out nor raise errors.'strict': Properties not in the schema will be filtered out and raise errors.- NOTE:
safetyonly applies to the.testand.parsefunctions, it does not affect.new.
options example:
type TUser = IUser | null | undefined;
const User = schema<TUser>({
id: isNumber,
name: isString,
}, {
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'
});Once you have your custom schema setup, you can call the .new, .test, .pick, and .parse functions.
NOTE: the following examples assume you set
0as the default forisNumber,''forisString, nothing forisOptionalString, andsafetyis left at its defaultfilteroption. See the Creating Schemas section for how to set default values and thesafetyoption.
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' }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 IUserSelects 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 Schema Options section).
const User = schema<IUser>({
id: isNumber,
address: schema<IUser['address']>({
street: isString,
city: isString,
}, { 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"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: isNumber,
name: isString,
});
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' }); // => ErrorIf 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());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.
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
});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.
- When passing the
Dateconstructor,jet-schemasets the type to be aDateobject and automatically converts all valid date values (i.e.string/number, maybe aDateobject got stringified in an API call) to aDateobject. The default value will be aDateobject with the current datetime. - You can also use an
enumas a validator. The default value will be the first value in the enum object and validation will make sure it is value of that enum. IMPORTANT this does not work for mixed enums see:eslint@typescript-eslint/no-mixed-enums
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:
dist/index.min.js: CommonJSdist/index.min.mjs: ESM (es6)
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,
} as const;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 { isNumber, isString, isBoolean } from 'util/validators.ts';
export default jetSchema({
globals: [
{ vf: isNumber, default: 0 },
{ vf: isString, default: '' },
{ vf: isBoolean, default: false },
],
});jet-validators is a library which contains a long list of the most commonly needed validator-functions. If you have a large application (such as an enterprise website), I recommended installing jet-validators as well to avoid having to define some of the more generic validators.
import { isNumber, isString, isBoolean } from 'jet-validators';
// Your custom validator
const isRelationalKey = (arg: unknown): arg is number => {
return isNumber(arg) && arg >= -1;
};
const schema = jetSchema({
globals: [
{ vf: isNumber, default: 0 },
{ vf: isString, default: '' },
{ vf: isBoolean, default: false },
{ vf: isRelationalKey, default: -1 },
],
});
const User = schema({
id: isRelationalKey,
name: isString,
});If you're wondering when to use jet-schema vs
parseObjectfrom jet-validators, a good rule of thumb is to use jet-schema for any objects that you are creating multiple instances of which may have predefined types (i.e. an object which represents a database table). For simple objects which just need a few fields validated and you aren't created multiple instances of them or applying types to them, just useparseObject.