- Simple and easy to use validation
- Takes type-safety seriously, with maximum inference
- Multiple constraints per-field
- Asynchronous constraints
- Fp-ts compatible version available
With yarn:
yarn add @josephluck/valley
With npm:
npm i --save @josephluck/valley
The most basic example is a validator with synchronous constraints:
import { makeValidator } from "@josephluck/valley";
/**
* Set up some rules. These can (and should be) shared
*/
const rules = {
greaterThan: (expected: number) => (value: number) =>
value > expected
? void null
: `Expected ${value} to be greater than ${expected}`,
divisibleBy: (div: number) => (value: number) =>
value % div === 0
? void null
: `Expected ${value} to be divisible by ${div}`,
};
/**
* Set up a type representing your form fields.
* This isn't strictly necessary as Valley can infer the shape of your form
* fields from the constraints you pass it, however it can be convenient to see
* the shape of your form fields explicitly.
*/
type Fields = {
twenty: number;
thirty: number;
fourty: number;
};
/**
* Create a validate function passing in a set of constraints per field.
*/
const validate = makeValidator<Fields>({
twenty: [rules.divisibleBy(10), rules.greaterThan(25)],
thirty: [rules.divisibleBy(10), rules.greaterThan(25)],
fourty: rules.divisibleBy(10),
});
/**
* Run the validate function over the constraints. Each constraint will run in
* the order passed, and the first to fail will be passed back as a message.
*/
const errors = validate({
twenty: 20,
thirty: 30,
fourty: 40,
});
console.log(errors);
// { twenty: "Expected 20 to be greater than 25", thirty: undefined, fourty: undefined }
Constraints can access other field values:
const rules = {
confirmPassword: (
value: string,
_key: string,
fields: { password: string; [key: string]: any }
) => (value === fields.password ? void null : "Passwords do not match"),
};
type Fields = {
email: string;
password: string;
confirmPassword: string;
};
const validate = makeValidator<Fields>({
email: [],
password: [],
confirmPassword: rules.confirmPassword,
});
const errors = validate({
email: "bob@acme.co",
password: "BobsDaBest",
confirmPassword: "BobsDaWorst",
});
console.log(errors);
// { email: undefined, password: undefined, confirmPassword: "Passwords do not match" }
Constraints can be asynchronous:
type Fields = {
email: string;
};
/**
* Usually these constraints would all be packaged up in a separate module for
* reusability, and imported where needed. Otherwise it looks a bit gnarly
* in-line...
*/
const validate = makeValidator<Fields>({
email: [
async (value) => {
try {
if (!value.includes(".") || !value.includes("@")) {
throw new Error("Not a valid email");
}
} catch (err) {
return err.message;
}
},
async (value) => {
try {
const response = await api.getAccount(value);
if (response.data) {
throw new Error("Account already exists");
}
} catch (err) {
return err.message;
}
},
],
});
const errors = await validate({
email: "bob@acme.co",
});
console.log(errors);
// { email: "Account already exists }
Valley is compatible with fp-ts using the fp variant of makeValidator
. This is useful if you're working with fp-ts
's Either
or TaskEither
types with pipe
to validate data before operating on it. If you want something more robust (with a higher learning curve), I'd recommend io-ts.
Constraints have to return Either<string, V>
or TaskEither<string, V>
depending on whether your constraint is synchronous or asynchronous (where T
represents the field's value, which should be returned by the constraint function if the field passes validation).
If all constraints return Either
s, the validation function will return Either<Record<keyof Fields, string>, Fields>
. If any constraint return a TaskEither
, the validation function will return TaskEither<Record<keyof Fields, string>, Fields>
. The left side of the either is the failed case containing validation messages, and the right side is the fields which are passed through if all constraints for all fields pass.
Here's an example of synchronous validation using Either
s:
import * as E from "fp-ts/lib/Either";
import { pipe } from "fp-ts/lib/pipeable";
import { makeValidator } from "@josephluck/valley/fp";
const isOfType = (type: string) => <T>(value: T): E.Either<string, T> =>
typeof value === type ? E.right(value) : E.left(`Expected a ${type}`);
const isString = isOfType("string");
const isNumber = isOfType("number");
const isEqualTo = <T>(expected: T) => <V extends T>(
value: V
): E.Either<string, V> =>
value === expected
? E.right(value)
: E.left(`Expected ${value} to equal ${expected}`);
const isGreaterThan = <T>(expected: T) => <V extends T>(
value: V
): E.Either<string, V> =>
value > expected
? E.right(value)
: E.left(`Expected ${value} to be greater than ${expected}`);
type Fields = {
name: string;
age: number;
};
const validate = makeValidator<Fields>({
name: [isString, isEqualTo("Bob")],
age: [isNumber, isGreaterThan(40)],
});
pipe(
validate({
name: "Bob",
age: 32,
}),
E.fold(
(errors) => {
/**
* If any constraint fails
*/
console.log(errors);
// { age: "Expected 32 to be greater than 40", name: undefined }
},
(fields) => {
/**
* If all constraints pass
*/
console.log(fields);
// { name: "Bob", age: 32 }
}
)
);
If any constraint returns a TaskEither
, the validate function returns a TaskEither
:
import * as E from "fp-ts/lib/Either";
import * as TE from "fp-ts/lib/TaskEither";
import { pipe } from "fp-ts/lib/pipeable";
import { makeValidator } from "@josephluck/valley/fp";
const isOfType = (type: string) => <T>(value: T): E.Either<string, T> =>
typeof value === type ? E.right(value) : E.left(`Expected a ${type}`);
const isString = isOfType("string");
const isNumber = isOfType("number");
const isEqualTo = <T>(expected: T) => <V extends T>(
value: V
): E.Either<string, V> =>
value === expected
? E.right(value)
: E.left(`Expected ${value} to equal ${expected}`);
const isGreaterThan = <T>(expected: T) => <V extends T>(
value: V
): E.Either<string, V> =>
value > expected
? E.right(value)
: E.left(`Expected ${value} to be greater than ${expected}`);
const asyncIsEqualTo = <T>(expected: T) => (value: T) =>
TE.tryCatch(
async () => {
if (value !== expected) {
throw new Error(`Expected ${value} to equal ${expected}`);
}
return value;
},
(err: Error) => err.message
);
const asyncIsGreaterThan = (expected: number) => (value: number) =>
TE.tryCatch(
async () => {
if (value < expected) {
throw new Error(`Expected ${value} to be greater than ${expected}`);
}
return value;
},
(err: Error) => err.message
);
type Fields = {
name: string;
age: number;
};
const validate = makeValidator<Fields>({
name: [isNumber, asyncIsEqualTo("Bob")],
age: [asyncIsGreaterThan(40), isString],
});
const pipeline = pipe(
validate({ name: "Bob", age: 50 }),
TE.mapLeft((errors) => {
/**
* If any constraint fails
*/
console.log(errors);
// { name: "Expected a number", age: "Expected a string" }
}),
TE.map((fields) => {
/**
* If all constraints pass
*/
console.log(fields);
// { name: "Bob", age: 50 }
})
);
await pipeline();