valentina
v0.2.1
Published
Dead simple JavaScript object validation
Downloads
4
Readme
Valentina: JavaScript object validation
Valentina is a tiny library for validating JavaScript values. Whether they be primitives, such as strings and numbers, or modelling more complex objects, Valentina will empower you to express your data validation rules, your way.
Killer Features:
- clear and transparent API, with no hidden surprises
- minimal and intuitive API, allowing for expressive JavaScript object schema and type definitions
- Powerful TypeScript support to infer static types from schema
- Composable validators, empowering you to define your own rules, your way
- zero dependencies
- install either via npm, or copy and paste the
lib.ts
(ordist/lib.js
for JavaScript) file into your project - no library lock-ins. So you used this library for a day, and now you hate it? As long as the next library defines their own
Validator
type, you should be able to migrate to that other library very easily. Or, you can quickly write your own
Getting Started
Schema creation is done by creating a validator. Valentina has simple creators, that will allow you to define your overall schema.
import {
object,
string,
number,
InferType,
either,
Validator,
} from "valentina";
// Create a validator that allows for values to be set to `undefined`
const optional = <T>(validator: Validator<T>) =>
either(validator, exact(undefined));
// You create a whole schema validators by composing other smaller validators
let userSchema = object({
name: string(),
age: number(),
address: optional(
object({
apartment: optional(string()),
streetNumber: string(),
streetName: string(),
})
),
});
type User = InferType<typeof userSchema>;
/*
type User = {
name: string
age: number
address: {
apartment: string | undefined
streetNmber: string
streetName: string
} | undefined
}
*/
A validator has a validate
method, which you can invoke for the purposes of validating data.
const data: any = {
name: "Jane",
age: 31,
address: { streetNumber: "123", streetName: "Peaceful Blvd" },
};
const result = userSchema.validate(data);
result.isValid;
// Will be 'true'
// In TypeScript, to cast the above `data` into a `user`, use a type guard
let user: User;
if (result.isValid) {
user = result.value;
}
// Simply defining `const str = result.value` will result in a compile-time
// error; type guards are used for casting purposes
These are the very basics that you can go off of, and start validating your data.
Table of Contents
- Usage Guide
- Design Philosophy
- API
- string(): Validator<string>
- number(): Validator<number>
- boolean(): Validator<boolean>
- exact<V extends string | number | boolean | null | undefined>(expected: V): Validator<V>
- either(...alts: Validator<any>[]): Validator<any>
- arrayOf<T>(validator: Validator<T>): Validator<T[]>
- any(): Validator<any>
- objectOf<T>(validator: Validator<T>): Validator<{ [key: string]: V }>
- tuple<T>(validator: Validator<T>): Validator<{ [key: string]: V }>
- except<T, I>(validator: Validator<T>, invalidator: Validator<I>): Exclude<T, I>
- object<V extends object>(schema: { [key in keyof V]: Validator<V[key]> }): Validator<V>
- lazy<V>(schemaFn: () => Validator<V>): Validator<V>
- Similar libraries
Usage Guide
The Valentina library was designed to be used for the purposes of validating incoming JSON data, whether they be from an HTTP request, an AJAX response, a WebSocket payload, etc.
const data = await fetch(url).then((response) => response.json());
const validation = schema.validate(data);
if (validation.isValid) {
const value = validation.value;
}
Valentina becomes especially powerful when larger Validator
s are comprised from smaller reusable validators, that serve their own purpose. These validators can then be used across multiple larger validators.
Installing
By design, Valentina places you under no obligation to use any package manager; you most certainly can copy and paste either lib.ts
for TypeScript, dist/lib.js
for CommonJS (require), or dist/esm/lib.js
for importing via the import
statement.
With that said, you are certainly more than welcomed to use a package manager, especially since it will allow you to easily upgrade, without the possibility of errors while copying and pasting.
npm (Node.js, Webpack, Vite, Browserify, or any other that use npm)
If you are using Node.js or a bundler that uses npm, Valentina has been published to npm for your convenience. You can install using your preferred package manager. For example:
- npm:
npm install valentina
- Yarn:
yarn add valentina
- pnpm:
pnpm add valentina
Additional, for the JavaScript CommonJS code, transpilers should not be needed. However if you are having trouble importing Valentina into your bundler (Webpack, Vite, Browserify, etc.), then please do open a new issue, and I will investigate.
Importing via CommonJS require
Once installed, you should be able to import the module via CommonJS require
, like so:
const valentina = require("valentina");
Importing via Node.js' ECMAScript module (import
statement)
In a Node.js mjs
file, you can simply import using the import
syntax.
import * as valentina from "valentina";
Deno
With Deno, you can directly import the lib.ts
file from GitHub.
import * as Valentina from "https://raw.githubusercontent.com/shovon/valentina/main/lib.ts";
Browser via import
statement (dist/esm/lib.js)
Warning
The
dist/lib.js
file will not work in browsers!You must use the
dist/esm/lib.js
file instead!
Option 1: Checking in an original copy of lib.js file into your project
Download or copy & paste the ECMAScript module located in dist/esm/lib.js
. Optionally, you can also grab a copy of the sourcemap file at dist/esm/lib.js.map
for debugging purposes.
<!-- Minified -->
<script src="/path/to/valentina/lib.js"></script>
Example application
Our chat application will have four events:
- user joined
- user left
- application state
- message sent
The user object will have two fields: id
and a name
.
export const userSchema = object({
id: string(),
name: string(),
});
export type User = InferType<typeof userSchema>;
/**
// Will be defined as
type User = {
id: string
name: string
}
*/
Both the "user joined" event and the "application state" event will have a User
type somewhere in their definition
Let's define the "user joined" event.
import { userSchema } from "./User";
// USER_JOINED
export const userJoinedSchema = object({
type: exact("USER_JOINED"),
// Compositon from another schema
data: userSchema,
});
export type UserJoined = InferType<typeof userJoinedSchema>;
// APPLICATION_STATE
export const applicationStateSchema = object({
type: exact("APPLICATION_STATE"),
// Compositon from another schema
data: arrayOf(userSchema),
});
export type ApplicationState = InferType<typeof applicationStateSchema>;
export const userLeftSchema = object({
type: exact("USER_LEFT"),
data: object({
id: string(),
}),
});
export type UserLeft = InferType<typeof userLeftSchema>;
And then, when a message was broadcast, the sent message from the server could have the following schema:
export const messageReceivedSchema = object({
type: exact("MESSAGE_RECEIVED"),
data: object({
from: string(),
whenSent: string(),
messageBody: string(),
}),
});
export type MessageReceived = InferType<typeof messageReceivedSchema>;
And finally, you can consolidate all events into a single schema, by using the either
validator.
export const eventSchema = either(
applicationStateSchema,
userJoinedSchema,
userLeftSchema,
messageReceivedSchema
);
export type Event = InferType<typeof eventSchema>;
Then, the application can handle events like so:
function handleMessage(value: Event) {
switch (value.type) {
case "USER_JOINED":
break;
case "APPLICATION_STATE":
break;
case "USER_LEFT":
break;
case "MESSAGE_RECEIVED":
break;
default:
console.error("Unknown type");
break;
}
}
For a full example, take a look at the example project.
Tips and tricks
Below are some tricks you can use to make Valentina an even more powerful validation tool.
Recursive types
If your application has data, whose schema comes in a tree-like structure (recursive type), then you can use the lazy
validator, so that your schema can work with TypeScript.
type Node = {
value: any;
left: Node | null;
right: Node | null;
};
const nodeSchema: Validator<Node> = lazy<Node>(() => {
object({
value: any(),
left: either(node, exact(null)),
right: either(node, exact(null)),
});
});
Create custom validators by composing other validators
Valentina offers some basic validator creators, such as for strings, numbers, and booleans. But, if you wanted to create your own validator creator for other purposes, you certainly can. Here's an example: optional values.
So let's say you wanted validation for an object with string fields that can be set to undefined
, then a validator for that would look like so:
either(string(), exact(undefined));
Let's generalize that validator, and have it be returned by a creator.
Such a function will look like so:
const optional = <T>(validator: Validator<T>) =>
either(validator, exact(undefined));
Same for nullable types:
const nullable = <T>(validator: Validator<T>) => either(validator, exact(null));
And, if you wanted validation for fields that are both nullable and optional, then you can define something like this:
const optionalNullable = <T>(validator: Validator<T>) =>
nullable(optional(validator));
Custom validators and parsing values
A validator doesn't need to exclusively be for validation, but it can also be used for transforming incoming data.
For instance, the JSON standard does not have a data type for Date
s. You can create a validator that will parse a string, and convert it to a JavaScript date.
import { ValidationError } from "valentina";
class DateError extends ValidationError {
constructor(value: string) {
super(
"Date error",
`The supplied string ${value} was not a valid format that can be parsed into a Date`,
value
);
}
}
export const date = (): Validator<Date> => ({
__: new Date(),
validate: (value: any) => {
const validation = string().validate(value);
if (validation.isValid === false) {
return { isValid: false, error: validation.error };
}
const d = new Date(validation.value);
return isNaN(d.getTime())
? {
isValid: false,
error: new DateError(validation.value),
}
: { isValid: true, value: d };
},
});
From which you can try to derive a date from a string.
Example:
const valid = new Date(new Date().toISOString());
const shouldBeValid = date().validate(validDate);
if (valid.isValid) {
valid.value; // This is the value
}
const invalid = new Date("invalid");
const shouldBeInvalid;
It gets even better. The above validator will have its type inferred as a Date
.
const dateSchema = date();
type CustomDate = InferType<typeof dateSchema>;
// type CustomDate = Date;
Note
In the above example, we created a custom error class called
DateError
, by deriving Valentina'sValidationError
class.By the definition of the
error
field inValidationResult<T>
, you don't have to use a class. You can simply initialize an object with the appropriate fileds.So in the above example, you could have simply defined a function, that returns an object, that has the appropriate fields.
For example.
function createDateError(value: string) { return { type: "Date error", errorMessage: `The supplied string ${value} was not a valid format that can be parsed into a Date`, value, }; }
The above would have been a perfectly valid error object.
With that said, using
ValidationError
provides the added advantage of capturing the call stack, allowing for easier debuggability, or even observability (you canJSON.stringify
the error, and all fields will be available in the resulting JSON, which you can log to a remote server, for further investigation)
Design Philosophy
Valentina is written with five principles in mind:
- Atomic validators
- Validator composition
- Embrace the language; use idioms
- Clarity and transparency
- Power to the client
Atomic Validators
Atomic
adjective
of or forming a single irreducible unit or component in a larger system.
The central idea is that complex validation rules can be built from the ground up out of atomic elements. Schema construction will happen through the composition of one or more of these "atoms". The Valentina library's Validator
is the type definition that serves as the "atom".
The definition of a Validator
is simply:
export type Validator<T> = {
__: T;
validate: (value: any) => ValidResult<T> | InvalidResult;
};
type ValidResult<T> = { value: T; isValid: true };
type InvalidResult = {
isValid: false;
error: {
readonly type: string;
readonly errorMessage: string;
readonly value: any;
} & { [key: string]: any };
};
Any object of type Validator
can implement the validate
method in their own way. It can even invoke the validate
method from another Validator
.
In fact, as far as this library is concerned, you can define your Validator
s not only by composing simpler validators defined in this library, but also by writing your own.
Composition
Rather than relying on—arguably—complex configurations and validation engines, Valentina empowers you to define schemas by composing smaller validators.
Additionally, you can easily re-use smaller validators across larger schemas.
The either
creator is a good example of validator composition. It allows you to define a validator for a value that could either be a string
or a number
.
const eitherStringOrNumber = either(string(), number());
eitherStringOrNumber.validate("10").isValid; // true
eitherStringOrNumber.validate(10).sValid; // true
eitherStringOrNumber.validate(true).sValid; // false
We can go even further. The object
creator will allow us to compose multiple validators for the purposes of allowing us to validate a more complex schema
For instance, in an application that sends you a message that only contains one user, or an array of users, you only need to define a User
schema once.
The following is the user schema:
export const userSchema = object({
id: string(),
name: string(),
});
And here are the schemas for the USER_JOINED
and APPLICATION_STATE
messages. The first message represents the values of a single user that has joined and the second one represents the current state of the application, which will contain an array of all users.
export const userJoined = object({
type: exact("USER_JOINED"),
data: userSchema,
});
export const applicationState = object({
type: exact("APPLICATION_STATE"),
data: arrayOf(userSchema),
});
None of the schema components are represented as a small part of a larger configuration object; instead they are all self-contained Validator
s.
Side note
Notice that the function composition chain becomes cumbersome?
You can potentially abstract those compositions away, to hide the inherent ugliness of it, but what would be even more awesome is if JavaScript supported the pipelining operator.
Part of the reason why this library exists is to nudge the TC39 to actually approve the proposed JavaScript pipeline operator
Embrace JavaScript itself; use idioms
Traditionally, validation libraries often resorted to abstracting away the minutiae behind JavaScript. According to those libraries, the idea is that JavaScript as a language is flawed, and those flaws need to be abstracted away.
Valentina, on the other hand, embraces JavaScript.
At it's core, the power of Valentina is all encompassed by the following type definitions:
export type Validator<T> = {
__: T;
validate: (value: any) => ValidResult<T> | InvalidResult;
};
type ValidResult<T> = { value: T; isValid: true };
type InvalidResult = {
isValid: false;
error: {
readonly type: string;
readonly errorMessage: string;
readonly value: any;
} & { [key: string]: any };
};
The
__
is only used for TypeScript type casting. You don't actually need ** in the actual JavaScript Validator object
You don't even need to use Valentina. As long as you can define your own Validator, you can perform validations as you would with Valentina.
Alternatively, you can even write your own Validator
s, and they will be 100% compatible with Valentina.
Clarity and transparency
This library does away with configurations. And thus, behaviour is never redefined using booleans, or other configuration options.
This makes behaviours of each validator creators testable and predictable.
Additionally, results from validation will be easily inspectable, and serializable into JSON via JSON.stringify
.
Power to the client
Want to go beyond what this library has to offer? You're in luck. Valentina won't lock you into using a quazi-proprietary addMethod
function. You are free to create your own validators.
Better yet, these validators can be used beyond just validation; but also data transformation.
For instance, if you have a string that represents a timestamp, you can convert the string to a JavaScript Date
.
import { ValidationError } from "valentina";
class DateError extends ValidationError {
constructor(value: string) {
super(
"Date error",
`The supplied string ${value} was not a valid format that can be parsed into a Date`,
value
);
}
}
export const date = (): Validator<Date> => ({
__: new Date(),
validate: (value: any) => {
const validation = string().validate(value);
if (validation.isValid === false) {
return { isValid: false, error: validation.error };
}
const d = new Date(validation.value);
return isNaN(d.getTime())
? {
isValid: false,
error: new DateError(validation.value),
}
: { isValid: true, value: d };
},
});
You can then use the date
validator creator to parse strings into a Date
.
const validation = date().validate("2022-03-04T23:44:42.086Z");
if (validation.isValid) {
// validation.value should be a Date, and not a string
}
API
string(): Validator<string>
Creates a validator for determining if a value is of type string.
Example:
const strValidator = string();
// ✅ Evaluates to true
strValidator.validate("").isValid;
// ❌ Evaluates to false
strValidator.validate(10).isValid;
number(): Validator<number>
Creates a validator for determining if a value is of type number.
Example:
const numberValidator = number();
// ✅ Evaluates to true
numberValidator.validate(10).isValid;
// ❌ Evaluates to false
numberValidator.validate("").isValid;
boolean(): Validator<boolean>
Creates a validator for determining if a value is of type boolean.
Example:
const boolValidator = boolean();
// ✅ Evaluates to true
boolValidator.validate(true).isValid;
// ✅ Evaluates to true
boolValidator.validate(false).isValid;
// ❌ Evaluates to false
boolValidator.validate("").isValid;
exact<V extends string | number | boolean | null | undefined>(expected: V): Validator<V>
Creates a validator that checks to see if the value exactly mathes the expected value passed in as the function's parameter.
Example:
const helloValidator = exact("hello");
// ✅ Evaluates to true
helloValidator.validate("hello").isValid;
// ❌ Evaluates to false
helloValidator.validate("").isValid;
either(...alts: Validator<any>[]): Validator<any>
Creates a validator for determining if a value is of any of the types as outlined in the supplied set of validators. In other words, you want a union of possible types.
So that means—for example—if you expect a value to be either of string, number, or boolean, you can use the either
function to create a validator to check for any of those types.
Example:
const eitherValidator = either(boolean(), string(), number());
// The resulting validator will be of type Validator<boolean | string | number>
// ✅ Evaluates to true
eitherValidator.validate(true).isValid;
// ✅ Evaluates to true
eitherValidator.validate(false).isValid;
// ✅ Evaluates to true
eitherValidator.validate("").isValid;
// ✅ Evaluates to true
eitherValidator.validate(10).isValid;
// ❌ Evaluates to false
boolValidator.validate({}).isValid;
arrayOf<T>(validator: Validator<T>): Validator<T[]>
Creates a validator for validating an array and the individual values that the array holds
Example:
const arrayValidator = arrayOf(string());
// The resulting validator will be of type Validator<string[]>
// ✅ Evaluates to true
arrayValidator.validate([]).isValid;
// ✅ Evaluates to true
arrayValidator.validate(["cool"]).isValid;
// ✅ Evaluates to true
arrayValidator.validate(["foo", "bar"]).isValid;
// ✅ Evaluates to true
arrayValidator.validate(["sweet"]).isValid;
// ❌ Evaluates to false
arrayValidator.validate({}).isValid;
// ❌ Evaluates to false
arrayValidator.validate([1, 2, 3]).isValid;
any(): Validator<any>
Creates a validator that will evaluate all values as being valid.
Example:
const anyValidator = any();
// Resulting in a validator that will be of type `Validator<any>`
// ✅ Evaluates to true
anyValidator.validate(true).isValid;
// ✅ Evaluates to true
anyValidator.validate(false).isValid;
// ✅ Evaluates to true
anyValidator.validate("").isValid;
// ✅ Evaluates to true
anyValidator.validate(10).isValid;
// ✅ Evaluates to true
anyValidator.validate(null).isValid;
Usage
any()
is especially useful in conjunction with arrayOf()
. arrayOf
checks not only if a value is an array, but it will also validate the individual items in said array. If you merely want an array, without checking any of the values, then any()
comes handy.
const arrayValidator = arrayOf(any());
// ✅ Evaluates to true
arrayValidator.validate([]).isValid;
// ✅ Evaluates to true
arrayValidator.validate([1, 2, 3]).isValid;
// ✅ Evaluates to true
arrayValidator.validate(["a", "b", "c"]).isValid;
// ✅ Evaluates to true
arrayValidator.validate([true, 1, "2", {}]).isValid;
// ❌ Evaluates to false
arrayValidator.validate(10).isValid;
// ❌ Evaluates to false
arrayValidator.validate(null).isValid;
objectOf<T>(validator: Validator<T>): Validator<{ [key: string]: V }>
Creates a validator for validating an object and the individual values (not field names) in said object.
Example:
const objectValidator = objectOf(string());
// ✅ Evaluates to true
objectValidator.validate([]).isValid;
// ✅ Evaluates to true
objectValidator.validate(["cool"]).isValid;
// ✅ Evaluates to true
objectValidator.validate(["foo", "bar"]).isValid;
// ✅ Evaluates to true
objectValidator.validate(["sweet"]).isValid;
// ❌ Evaluates to false
objectValidator.validate({}).isValid;
// ❌ Evaluates to false
objectValidator.validate([1, 2, 3]).isValid;
tuple<T>(validator: Validator<T>): Validator<{ [key: string]: V }>
Creates a validator for validating an object and the individual values (not field names) in said object.
Example:
const objectValidator = tuple(string());
// ✅ Evaluates to true
objectValidator.validate([]).isValid;
// ✅ Evaluates to true
objectValidator.validate(["cool"]).isValid;
// ✅ Evaluates to true
objectValidator.validate(["foo", "bar"]).isValid;
// ✅ Evaluates to true
objectValidator.validate(["sweet"]).isValid;
// ❌ Evaluates to false
objectValidator.validate({}).isValid;
// ❌ Evaluates to false
objectValidator.validate([1, 2, 3]).isValid;
except<T, I>(validator: Validator<T>, invalidator: Validator<I>): Exclude<T, I>
Given two Validator
s—one acting as a "validator" and another as an "invalidator"—the function except
creates a Validator
that determines it is indeed a value in accordance to the validator, except something defined by the supplied invalidator.
Example:
const everythingButValidator = except(string(), exact("but"));
// ✅ Evaluates to true
everythingButValidator.validate("apples").isValid;
// ✅ Evaluates to true
everythingButValidator.validate("bananas").isValid;
// ✅ Evaluates to true
everythingButValidator.validate("cherries").isValid;
// ❌ Evaluates to false
everythingButValidator.validate("but").isValid;
Note
Due to a limitation in TypeScript, we are unable derive a subset of an "unbounded" type. For example,
Exclude<string, "but">
(set of all strings except for the string"but"
) will merely evaluate tostring
. Likewise,Exclude<any, undefined>
will simply evaluate toany
.
Usage
In some instances, it may not matter the exact content of a value, so long as it is not undefined. The except
function can create a Validator
that checks for any value that isn't undefined.
const notUndefinedValidator = except(any(), exact(undefined));
// ✅ Evaluates to true
notUndefinedValidator.validate("cool").isValid;
// ✅ Evaluates to true
notUndefinedValidator.validate(19).isValid;
// ✅ Evaluates to true
notUndefinedValidator.validate([]).isValid;
// ❌ Evaluates to false
notUndefinedValidator.validate(undefined).isValid;
object<V extends object>(schema: { [key in keyof V]: Validator<V[key]> }): Validator<V>
Given an object of Validators, creates a validator for an object, with validation for specific keys in the object.
Example:
const objValidator = object({
type: exact("SOME_OBJ"),
value: string(),
someNumber: number(),
somethingOptional: either(string(), exact(undefined)),
});
// ✅ Evaluates to true
objValidator.validate({ type: "SOME_OBJ", value: "something", someNumber: 10 })
.isValid;
// ✅ Evaluates to true
objValidator.validate({
type: "SOME_OBJ",
value: "something",
someNumber: 10,
sometihingOptional: "sweet",
}).isValid;
// ✅ Evaluates to true
objValidator.validate({ type: "SOME_OBJ", value: "something", someNumber: 10 })
.isValid;
// ❌ Evaluates to false (the field `type` is set to something other than
// `SOME_OBJ`)
objValidator.validate({ type: "something", value: "something", someNumber: 10 })
.isValid;
lazy<V>(schemaFn: () => Validator<V>): Validator<V>
The lazy validator allows you to wrap another validator in a callback.
const everythingButValidator = lazy(() => except(string(), exact("but")));
// ✅ Evaluates to true
everythingButValidator.validate("apples").isValid;
// ✅ Evaluates to true
everythingButValidator.validate("bananas").isValid;
// ✅ Evaluates to true
everythingButValidator.validate("cherries").isValid;
// ❌ Evaluates to false
everythingButValidator.validate("but").isValid;
Motivation and usage
If your application has data, whose schema comes in a tree-like structure (recursive type), then you can use the lazy
validator, so that your schema can work.
type Node = {
value: any;
left: Node | null;
right: Node | null;
};
const nodeSchema: Validator<Node> = lazy<Node>(() => {
object({
value: any(),
left: either(node, exact(null)),
right: either(node, exact(null)),
});
});
Similar libraries
Valentina was inspired by yup.js. It's a good library, but Valentina's purpose is to limit the number of dependencies that JavaScript projects rely on