Jet-Schema ✈️
Simple, zero-dependency, typescript-first schema validation tool, that lets you integrate your own validator-functions.
Table of contents
Introduction
Most schema validation libraries have fancy functions for validating objects and their properties, but the problem is I already have a lot of my own custom validation functions specific for each of my applications that I also like to copy-n-paste a lot and modify as needed (i.e. functions to check primitive-types, regexes for validating strings etc). The only thing that was making me use schema-validation libraries was trying to validate an object. So I thought, why not figure out a way to integrate my all the functions I had already written with something that can validate them against object properties? Well jet-schema does just that :)
I know some libraries like zod
have functions such as .refine()
which allow you to integrate custom validation logic. But this isn't quite the same because you could still chain additional validation logic onto .refine
and you still have to look through the documentation to see how to do it. I wanted a library that allowed me to just slap in validator-functions that were not tied to any specific schema-validation library.
If you want a library that includes all kinds of special functions for validating things other than objects, jet-schema is probably not for you. However, the vast majority of projects I've worked on have involved implementing lots of type-checking functions specific to the needs of that project. For example, maybe you use another datetime handling library other than JavasScript's Date
object (i.e. DayJs
). Instead of of having to dig into the library's features to accept dayjs
objects as valid-objects, with jet-schema you can just drop in dayjs.isValid
.
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.
Reasons to use Jet-Schema 😎
- Focus is on using your own validator-functions to validate object properties (this is why I wrote it).
- TypeScript first!
- Quick, terse, simple, easy-to-use (this library only exports 2 functions and 2 types).
- Much smaller and less complex than most schema-validation libraries.
- Typesafety works both ways, you can either force a schema structure using a pre-defined type OR you can infer a type from a schema.
new
, test
, parse
functions provided automatically on every new schema.- Setting defaults and transforming values can be set globally on initial setup or at the schema-level.
- Works client-side or server-side.
- Enums can be used for validation.
Date
constructor can be used to automatically transform and validate any valid date value.- Doesn't require a compilation step (so still works with
ts-node
, unlike typia
).
Quick Glance
import schema from 'utils/schema';
import { isRelKey, isString, isNumber, isOptionalStr } from 'utils/validators';
interface IUser {
id: number;
name: string;
email: string;
age: number;
created: Date;
address?: {
street: string;
zip: number;
country?: string;
};
}
const User = schema<IUser>({
id: isNumber,
name: isString,
email: { vf: isEmail, default: 'x@x.com' },
age: { vf: isNumber, transform: Number },
created: Date,
address: schema<IUser['address']>({
street: isString,
zip: isNumber,
country: isOptionalStr,
}, { optional: true }),
});
Guide
Getting Started
Note that at the heart of jet-schema
are validator-functions. Since functions are objects in javascript, and objects are pass by reference, jet-schema
will map certain settings to validator-functions by using the functions themselves as a reference. This is what enables you to pass your existing validator-functions to your schema without have to deal with the library's features for every object-property.
npm install -s jet-schema
After installation, you need to configure the schema
function by importing and calling jetSchema()
.
jetSchema()
accepts an optional settings object with 3 three optional properties:
globals
: An array of validator-objects, which set certain default settings for specific validator-functions. You should use this option for frequently used validator-function/default/transform combinations where you don't want to set a default value or transform-function every time. Upon initialization, the validator-functions will check their defaults. If a value is not optional and you do not supply a default value, then an error will occur when the schema is initialized. If you don't set a default value for a validator-function in jetSchema()
, you can also do so when setting up an individual schema (the next 3 snippets below contain examples).
The format for a validator-object is:
{
vf: <T>(arg: unknown) => arg is T;
default?: T;
transform?: (arg: unknown) => T;
onError?: (property: string, value?: unknown, moreDetails?: string, schemaId?: string) => void;
}
cloneFn
: A custom clone-function if you don't want to use the built-in function which uses structuredClone
(I like to use lodash.cloneDeep
).onError
: A global error handler. By default, if a validator-function fails then an error is thrown. You can override this behavior by passing a custom error handling function as the third argument. This feature is really useful for testing when you may want to return an error string instead of throw an error. Format, (property: string, value?: unknown, origMessage?: string, schemaId?: string) => void;
.
When setting up jet-schema for the first time, usually what I do is create two files under my util/
folder: schema.ts
and validators.ts
. In schema.ts
I'll import and call the jet-schema
function then apply any globals and a custom clone-function. If you don't want to go through this step, you can import the schema
function directly from jet-schema
.
export const isStr = (arg: unknown): arg is string => typeof param === 'string';
export const isOptStr = (arg: unknown): arg is string => arg === undefined || typeof param === 'string';
export const isNum = (arg: unknown): arg is string => typeof param === 'number';
⚠️ IMPORTANT: You need to use type-predicates when writing validator-functions. If a value can be null/undefined
, your validator-function's type-predicate needs account for this (i.e. (arg: unknown): arg is string | undefined => ...
).
import jetSchema from 'jet-schema';
import { isNum, isStr } from './validators';
export default jetLogger({
globals: [
{ vf: isNum, default: 0 },
{ vf: isStr, default: '' },
],
cloneFn: () => ...
onError: () => ...
});
Creating custom schemas
Now that we have our schema
function setup, let's make a schema. Simply import the schema
function from util/schema.ts
and your existing validator-functions, then pass them as the value to each property in the schema
function or use a validator-object. The format for a validator-object is the same both locally and globally (see above). All local-settings will for a validator-function will overwrite the global ones. Remember that if a property is required then a default must be set for its validator-function (locally or globally) or else new
won't know what to use as a value when passing a partial.
For handling an individual schema's type, there are two ways to go about this, enforcing a schema from a type or infering a type from a schema. I'll show you an example of doing it both ways.
Personally, I like to create an interface first cause I feel like interfaces are great way to document your data-types; however, I created inferType
because I know some people prefer to setup their schemas first and infer their types from that.
import { inferType } from 'jet-schema';
import schema from 'util/schema.ts';
import { isNum, isStr, isOptionalStr } from 'util/validators.ts';
interface IUser {
id: number;
name: string;
email: string;
nickName?: string;
}
const User = schema<IUser>({
id: isNum,
name: isStr,
email: { vf: isEmail, default: '' },
nickName: isOptionalStr,
})
const User = schema({
id: isNum,
name: isStr,
email: { vf: isEmail, default: '' },
nickName: isOptionalStr,
})
const TUser = inferType<typeof User>;
Once you have your custom schema setup, you can call the new
, test
, pick
, and parse
functions. Here is an overview of what each one does:
new
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. Also, if you pass no parameter then a new instance will be created using all the defaults.test
accepts any unknown value, tests that it's valid, and returns a type-predicate.pick
allows you to select any property and returns an object with the test
and default
functions.parse
is 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. If you have an incoming unknown value (i.e. an api call) and you want to validate the properties and return a new cleaned instance, use parse
. Note: only objects will pass the parse
function, even if a schema is nullish, null/undefined
values will not pass.
Making schemas optional/nullable
In addition to a schema-object, the schema
function accepts an additional options object parameter. The values here are type-checked against the generic (schema<"The Generic">(...)
) that was passed so you must use the correct values. If your generic is optional/nullable then your are required to pass the object so at runtime the correct values are parsed.
{
optional?: boolean;
nullable?: boolean;
init?: boolean | null;
nullish?: true;
id?: string;
}
The option init
defines the behavior when a schema is a child-schema and is being initialized from the parent. If true (default)
, then a nested child-object will be added to the property when a new instance of the parent is created. However, if a child-schema is optional or nullable, maybe you don't want a nested object and just want it to be null
or skipped entirely. If init
is null
then nullable
must be true
, if false
then optional
must be true
.
In the real world it's very common to have a lot of schemas which are both optional, nullable. So you don't have to write out { optional: true, nullable: true }
over-and-over again, you can write { nullish: true }
as an shorthand alternative.
You can also set the optional id
field, if you need a unique identifier for your schema for whatever reason. If you set this option then it will be added to the default error message. This can be useful if you have to debug a bunch of schemas at once (that's pretty much all I use it for).
Here's an example of the options in use:
interface IUser {
id: number;
name: string;
address?: { street: string, zip: number } | null;
}
const User = schema<IUser>({
id: isNumber,
name: isString,
address: schema<IUser['address']>({
street: isString,
zip: isNumber,
}, { nullish: true, init: false, id: 'User_address' }),
})
User.new()
Child (aka nested) schemas
- If an object property is a mapped-type, then it must be initialized with the
schema
function. - Just like with the parent schemas, you can also call
new
, test
, pick
, parse
in addition to default
. Note: the value returned from default
could be different from new
if the schema is optional/nullable and the default value is null
or undefined
. - There is one extra function
schema()
that you can call when using pick
on a child-schema. This can be handy if you need to export a child-schema from one parent-schema to another:
interface IUser {
id: number;
address?: { street: string, city: string };
}
const User = schema<IUser>({
id: isNumber,
address: schema<IUser['address']>({
street: isStr,
city: isString
}, { optional: true, init: false }),
});
User.pick('address').default()
User.pick('address').new()
interface IUserAlt {
id: number;
address?: IUser['address'];
}
const UserAlt = schema<IUserAlt>({
id: isNumber,
address: User.pick('address').schema(),
});
Combining Schemas
For whatever reason, your schema may end up existing in multiple places. 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 one, then merge it with your full schema later.
import schema, { TJetSchema } from 'jet-schema';
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
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 arg is string
will still work. However a if a type predicate returns arg 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,
name: isOptionalString,
})
Child schemas
As mentioned, if a property in a parent is 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,
}, { 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
- When passing the
Date
constructor, jet-schema
sets the type to be a Date
object and automatically converts all valid date values (i.e. string/number
, maybe a Date
object got stringified in an API call) to a Date
object. The default value will be a Date
object with the current datetime. - You can also use an enum as 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.
Miscellaneous Notes
Creating wrapper functions
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.
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 {
checkAddr: nonNullable(User.pick('address').test),
...User,
}
Recommended Defaults
When calling the jetSchema
function for this first time, at the very least, 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 jetLogger({
globals: [
{ vf: isNum, default: 0 },
{ vf: isStr, default: '' },
{ vf: isBool, default: false },
],
});