easyspec
v0.2.0
Published
Data validation utility
Downloads
8
Maintainers
Readme
Easyspec
Object validation library.
It provides Validate
method that takes two arguments:
- ValidationSpec
- Object of type T
Validate
A binary function that takes two arguments:
- validation specification
- an object
It checks whether object adheres to the specification.
and returns validation summary
Validation specification looks like this:
export type ValidationSpec<T1 extends DataObject> = {
[P in keyof T1]:
| ValidationSpec<Required<T1>[P]> // spec for nested obj
| ValidationPropertyRule<T1, P>[] // or an array of validationPropertyRules
} & { [ValidationOptionsSym]?: ValidationOptions<T1> }; // optional options
export type ValidationPropertyRule<T1, P extends keyof T1> = [ // tuple of:
(v: T1[P], k: P, o: T1) => boolean, // 0.validator
(v: T1[P], k: P, o: T1) => string // 1.message factory
];
Options
export type ValidationOptions<T extends DataObject> = {
// used to increase performance in case you are only interested in
// validity of an object and not in all the properties that are invalid
stopAfterInvalid?: boolean,
// allows you to create custom messages in case validator throws an exception
errorHandler?: (e: ValidationException) => string,
// should redundant properties make object invalid (true by default)
redundantIsError?: boolean,
// properties of an object that are allowed to be null or undefined
optionalProps?: (keyof T)[],
// whether object is allowed to be null or undefined
isOptional?: boolean
}
// errorHandler option input value type looks like this:
export type ValidationException = {
key: string, // key that caused error
value: any, // value that caused error
ruleIndex: number, // index of the rule where error occured
error: Error // exception itself
}
You don't have to provide any of the options, there's a default value for every option. Default options look like this:
const defaultOptions = {
optionalProps: [],
redundantIsError: true,
stopAfterInvalid: false,
errorHandler: ({key}) => `Error while validating property "${key}".`,
isOptional: false
}
Validation summary
The result of the Validate
function is of type ValidationSummary
export type ValidationSummary<T1 extends DataObject> = {
valid: boolean, // whether object is considered valid
errorCount: number, // number of errors
missingProperties: string[], // missing properties
redundantProperties: string[], // redundant properties
errors: Record<keyof T1 | '_self', string[]> // error messages that occured
}
Examples
Simple objects
type CatChild = {
age?: number;
name?: string;
weight?: number;
}
const CatChildSpec: ValidationSpec<CatChild> = {
age: [
[ // validator function takes these three parameters
// NOTE THE TYPES (they can be inferred)
(v: number, k: 'age', o: CatChild) => typeof v === 'number',
// message factory function same three parameters
(value, key, obj) => `${key} must be of type number but was of type ${typeof value}`
],
[
(value, key, obj) => value > 0,
(value, key, obj) => `${key} must be greater than 0 but was ${value}`
],
],
name: [
[
(value, key, obj) => typeof value === 'string',
(value, key, obj) => `${key} must be of type string but was of type ${typeof value}`
],
],
weight: [
[
(value, key, obj) => typeof value === 'number',
(value, key, obj) => `${key} must be of type number but was of type ${typeof value}`
],
],
// to specify options you need to import <ValidationOptionsSym> symbol
[ValidationOptionsSym]: {
// according to the type above all properties should be optional
optionalProps: ['name', 'age', 'weight'],
}
}
const validCatChild: CatChild = {
age: 1,
name: 'Tonny',
weight: 4,
}
const summary = Validate(CatChildSpec, validCatChild);
Result will be:
{
"valid": true,
"errorCount": 0,
"missingProperties": [],
"redundantProperties": [],
"errors": {}
}
const validCatChild: CatChild = {
age: 1,
name: 'Tonny',
// weight: 4, # because every property is optional you can safely omit any of them
}
const summary = Validate(CatChildSpec, validCatChild);
Result will be:
{
"valid": true,
"errorCount": 0,
"missingProperties": [], # weight is not considered missing because it's optional
"redundantProperties": [],
"errors": {}
}
When we pass invalid object:
const invalidCatChild: CatChild = {
age: 'Tom',
name: 8,
weight: { number: 3 },
isTiger: false
}
const summary = Validate(CatChildSpec, invalidCatChild);
Result will be:
{
"valid": false, # object is invalid
"errorCount": 5, # 5 errors were found
"missingProperties": [],
"redundantProperties": [
"isTiger" # isTiger property is redundant
],
"errors": { # "errors" is an object that contains
"age": [ # an array of errors for every property:
"age must be of type number but was of type string",
"age must be greater than 0 but was Tom"
],
"name": [
"name must be of type string but was of type number"
],
"weight": [
"weight must be of type number but was of type object"
]
}
}
Reducing amount of code
To reduce the amount of code that you need to write to declare a spec you can try using some helper function library such as:
- lodash
- fp-way-core
- ramda
For this example I am going to use fp-way-core
.
Let's declare the same spec as we did above, but now using the lib.
// create a helper for common functions
const TypeErrorFactory = (t) => (v, k) => `${k} must be of type ${t} but was of type ${TypeOf(v)}`
const CatChildSpec: ValidationSpec<CatChild> = {
age: [
[IsOfType('number'), TypeErrorFactory('number')],
[Gt(0), (v, k) => `${k} must be greater than 0 but was ${v}`],
],
name: [
[IsOfType('string'), TypeErrorFactory('string')],
],
weight: [
[IsOfType('number'), TypeErrorFactory('number')],
],
[ValidationOptionsSym]: {
optionalProps: ['name', 'age', 'weight'],
}
}
Nested objects and nested specs
type CatChild = {
age?: number;
name?: string;
weigth?: number;
}
type Cat = {
age: number;
name?: string;
weigth: number;
child: CatChild // we are going to apply NESTED SPEC to validate this property
}
type CatParent = {
age?: number;
name: string;
weigth: number;
childCat: Cat; // we are going to apply NESTED SPEC to validate this property
}
const CatChildSpec: ValidationSpec<CatChild> = {
age: [
[IsOfType('number'), TypeErrorFactory('number')],
[Gt(0), (v, k) => `${k} must be greater than 0 but was ${v}`],
],
name: [
[IsOfType('string'), TypeErrorFactory('string')]
],
weight: [
[IsOfType('number'), TypeErrorFactory('number')]
],
[ValidationOptionsSym]: {
optionalProps: ['name', 'age', 'weight'],
isOptional: true,
}
}
const CatSpec: ValidationSpec<Cat> = {
age: [
[IsOfType('number'), TypeErrorFactory('number')],
],
name: [
[IsOfType('string'), TypeErrorFactory('string')]
],
weight: [
[IsOfType('number'), TypeErrorFactory('number')]
],
child: CatChildSpec, // just pass the SPECIFICATION for the
// nested object instead of an array of validators
[ValidationOptionsSym]: {
optionalProps: ['name']
}
}
const CatParentSpec: ValidationSpec<CatParent> = {
age: [
[IsOfType('number'), TypeErrorFactory('number')],
],
name: [
[IsOfType('string'), TypeErrorFactory('string')]
],
weight: [
[IsOfType('number'), TypeErrorFactory('number')]
],
childCat: CatSpec, // just pass the SPECIFICATION for the
// nested object instead of an array of validators
[ValidationOptionsSym]: {
optionalProps: ['age']
}
}
const cat: Cat = {
// age: 1, // missing
color: 'grey', // redundant
name: 1,
weight: 4,
child: { // nested child
name: 'Tonny jr',
age: '1',
},
} as any;
const catParent: CatParent = {
age: 'old',
name: 'Tonny Sr',
weight: 'overweight',
childCat: cat // nested cat
} as any
const result = Validate(CatParentSpec, catParent);
Result will be:
{
"valid": false,
"errorCount": 6,
"missingProperties": [
"childCat.age"
],
"redundantProperties": [
"childCat.color"
],
"errors": {
"age": [
"age must be of type number but was of type string"
],
"weight": [
"weight must be of type number but was of type string"
],
"childCat.name": [
"name must be of type string but was of type number"
],
"childCat.child.age": [
"age must be of type number but was of type string"
]
}
}
Please note the way nested object errors are presented.
They are presented using dot notation
: "childCat.child.age"
If you want to change format of the message you can try to use function to validate the nested object as usual:
const CatSpec: ValidationSpec<Cat> = {
child: [
[
(v, k, o) => v?.age > 0,
(v, k, o) => `${k} age must be greater than 0 but was ${v?.age}`
]
]
}
Extending specs
As you can see above there's quite a lot of repetition when declaring specs for similar types. To only write necessary specification details for different types there's Extend method.
Extend method takes two arguments:
- extension specification
- parent specification
Extension specification
Similar to validation specification but:
- Two options are required:
omitKeys
andoptionalProps
, this is done so that you don't forget to remove properties that don't exist on the new type and rewrite optional properties in accordance to the new type. - Only keys that are NOT present on the parent type are required .
Example
type City = { area?: number, name: string }
type LocalArea = { area: number, city: string }
const CitySpec: ValidationSpec<City> = {
area: [ [IsOfType('number'), TypeErrorFactory('number')] ],
name: [ [IsOfType('string'), TypeErrorFactory('string')] ],
[ValidationOptionsSym]: { optionalProps: ['area'] }
}
const LocalAreaSpec = Extend<City, LocalArea>({ // from city -> to -> localArea spec
// 1. only add properties that don't exist on the parent spec
city: [ [IsOfType('string'), TypeErrorFactory('string')] ],
// area propertyRule will be inherited from the parent
[ValidationOptionsSym]: {
// 2. omit parent spec properties using omitkeys option
omitKeys: ['name'],
// 3. override optionalProperties option
// (in the parent spec it says that we have optional property 'name'
// which is not true for the current spec)
optionalProps: [],
}
}, CitySpec);
In the example above you can see how to easily add and remove properties from the parent specification. But you can also: Override parent options and property rules:
const LocalAreaSpec = Extend<City, LocalArea>({
// city property is not present on the parent spec,
// so it's required that you provide property rules for it
city: [ [IsOfType('string'), TypeErrorFactory('string')] ],
// 1. area property rule will be completely overriden
// 2. area property is present on the parent so overriding it is optional
area: [
[IsOfType('number'), TypeErrorFactory('number')],
[Gt(0), (v, k) => `${k} must be greater than 0 but was ${v}`],
],
[ValidationOptionsSym]: {
// these options are required when extending
omitKeys: ['name'],
optionalProps: [],
// you can specify more options to substitute those on the parent options object
// these options are optional when extending
redundantIsError: true,
stopAfterInvalid: false,
errorHandler: ({key, ruleIndex}) => `Error while validating property "${key}" at rule index ${ruleIndex}`,
isOptional: false
}
}, CitySpec);
Speed comparison
🔨 to be continued...