Security News
Input Validation Vulnerabilities Dominate MITRE's 2024 CWE Top 25 List
MITRE's 2024 CWE Top 25 highlights critical software vulnerabilities like XSS, SQL Injection, and CSRF, reflecting shifts due to a refined ranking methodology.
jet-schema
Advanced tools
Simple, zero-dependency, typescript-first schema validation tool, that lets you integrate your own validator-functions.
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 :)
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 the email format that's built into the library is different than the one your application needs. Instead of of having to dig into the library's features to run validations specific to your needs, with jet-schema you can just pass your method.
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.
new
, test
, parse
functions provided automatically on every new schema.Date
constructor can be used to automatically transform and validate any valid date value.ts-node
, unlike typia
).
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: isRelKey,
name: isString,
email: { fn: isEmail, default: 'x@x.com' },
age: { fn: isNumber, transform: Number },
created: Date,
address: schema<IUser['address']>({
street: isString,
zip: isNumber,
country: isOptionalStr,
}, { optional: true }),
});
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 everytime.
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 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:
{
fn: <T>(arg: unknown) => arg is T; // a validator-function
default?: T; // the value to use for the validator-function
transform?: (arg: unknown) => T; // modify the value before calling the validator-function
onError?: (property: string, value?: unknown, moreDetails?: string, schemaId?: string) => void; // Custom error message for the function
}
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.
(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
.
// "util/validators.ts"
// As mentioned in the intro, you can copy some predefined validators from here (https://github.com/seanpmaxwell/ts-validators/blob/master/src/validators.ts)
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 => ...
).
// "util/schema.ts"
import jetSchema from 'jet-schema';
import { isNum, isStr } from './validators';
export default jetLogger({
globals: [
{ fn: isNum, default: 0 },
{ fn: isStr, default: '' },
],
cloneFn: () => ... // pass a custom clone-function here if you want
onError: () => ... // pass a custom error-handler here if you want,
});
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.
// "models/User.ts"
import { inferType } from 'jet-schema';
import schema from 'util/schema.ts';
import { isNum, isStr, isOptionalStr } from 'util/validators.ts';
// **OPTION 1**: Create schema using type
interface IUser {
id: number;
name: string;
email: string;
nickName?: string;
}
const User = schema<IUser>({
id: isNum,
name: isStr,
email: { fn: isEmail, default: '' },
nickName: isOptionalStr,
})
// **OPTION 2**: Create type using schema
const User = schema({
id: isNum,
name: isStr,
email: { fn: 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.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; // default "false", must be true if generic is optional
nullable?: boolean; // default "false", must be true if generic is nullable
init?: boolean | null; // default "true", must be undefined, true, or null if generic is not optional.
nullish?: true; // Use this instead of "{ optional: true, nullable: true; }"
id?: string; // Will identify the schema in error messages
}
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:
// models/User.ts
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() // => { id: 0, name: '' }
schema
function.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
.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() // => undefined because we said "init: false"
User.pick('address').new() // { street: '', city: '' }
interface IUserAlt {
id: number;
address?: IUser['address'];
}
const UserAlt = schema<IUserAlt>({
id: isNumber,
address: User.pick('address').schema(),
});
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());
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, // "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 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,
// 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.
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.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,
}
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.
// util/schema.ts
import { isNum, isStr, isBool } from 'util/validators.ts';
export default jetLogger({
globals: [
{ fn: isNum, default: 0 },
{ fn: isStr, default: '' },
{ fn: isBool, default: false },
],
});
FAQs
Simple, typescript-first schema validation tool
The npm package jet-schema receives a total of 77 weekly downloads. As such, jet-schema popularity was classified as not popular.
We found that jet-schema demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 0 open source maintainers collaborating on the project.
Did you know?
Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.
Security News
MITRE's 2024 CWE Top 25 highlights critical software vulnerabilities like XSS, SQL Injection, and CSRF, reflecting shifts due to a refined ranking methodology.
Security News
In this segment of the Risky Business podcast, Feross Aboukhadijeh and Patrick Gray discuss the challenges of tracking malware discovered in open source softare.
Research
Security News
A threat actor's playbook for exploiting the npm ecosystem was exposed on the dark web, detailing how to build a blockchain-powered botnet.