0.2.0 • Published 2 years ago

easyspec v0.2.0

Weekly downloads
-
License
MIT
Repository
github
Last release
2 years ago

Easyspec

Object validation library. It provides Validate method that takes two arguments: 1. ValidationSpec 2. Object of type T

Validate

A binary function that takes two arguments: 1. validation specification 2. 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: 1. extension specification 2. parent specification

Extension specification

Similar to validation specification but: 1. Two options are required: omitKeys and optionalProps, 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. 2. 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...

0.2.0

2 years ago

0.1.3

2 years ago

0.1.2

2 years ago

0.1.1

2 years ago

0.1.0

2 years ago