From 87e4ec2edd29cdd7764fb1ebd4e680461fd214d7 Mon Sep 17 00:00:00 2001 From: Sam Verschueren Date: Fri, 1 Feb 2019 08:47:39 +0100 Subject: [PATCH] Add object shape validators (#127) --- readme.md | 21 ++++++++ source/index.ts | 10 ++-- source/lib/predicates/object.ts | 54 +++++++++++++++++++ source/lib/test.ts | 13 +++++ source/lib/utils/match-shape.ts | 80 +++++++++++++++++++++++++++++ source/predicates.ts | 5 +- source/test/object.ts | 91 +++++++++++++++++++++++++++++++++ 7 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 source/lib/test.ts create mode 100644 source/lib/utils/match-shape.ts diff --git a/readme.md b/readme.md index 1da8fea..26928e9 100644 --- a/readme.md +++ b/readme.md @@ -43,6 +43,27 @@ unicorn('yo'); //=> ArgumentError: Expected string `input` to have a minimum length of `5`, got `yo` ``` +We can also match the shape of an object. + +```ts +import ow from 'ow'; + +const unicorn = { + rainbow: '🌈', + stars: { + value: '🌟' + } +}; + +ow(unicorn, ow.object.exactShape({ + rainbow: ow.string, + stars: { + value: ow.number + } +})); +//=> ArgumentError: Expected property `stars.value` to be of type `number` but received type `string` in object `unicorn` +``` + ## API diff --git a/source/index.ts b/source/index.ts index bc641bb..7050e5a 100644 --- a/source/index.ts +++ b/source/index.ts @@ -1,9 +1,10 @@ import callsites from 'callsites'; import {inferLabel} from './lib/utils/infer-label'; import {Predicate} from './lib/predicates/predicate'; -import {testSymbol, BasePredicate, isPredicate} from './lib/predicates/base-predicate'; +import {BasePredicate, isPredicate} from './lib/predicates/base-predicate'; import modifiers, {Modifiers} from './modifiers'; import predicates, {Predicates} from './predicates'; +import test from './lib/test'; /** * @hidden @@ -49,10 +50,6 @@ export interface Ow extends Modifiers, Predicates { create(label: string, predicate: BasePredicate): (value: T) => void; } -const test = (value: T, label: string | Function, predicate: BasePredicate) => { - predicate[testSymbol](value, test, label); -}; - const ow = (value: T, labelOrPredicate: any, predicate?: BasePredicate) => { if (!isPredicate(labelOrPredicate) && typeof labelOrPredicate !== 'string') { throw new TypeError(`Expected second argument to be a predicate or a string, got \`${typeof labelOrPredicate}\``); @@ -110,5 +107,6 @@ export { WeakMapPredicate, SetPredicate, WeakSetPredicate, - AnyPredicate + AnyPredicate, + Shape } from './predicates'; diff --git a/source/lib/predicates/object.ts b/source/lib/predicates/object.ts index e7f9ebd..8adab3e 100644 --- a/source/lib/predicates/object.ts +++ b/source/lib/predicates/object.ts @@ -5,6 +5,9 @@ import {Predicate, PredicateOptions} from './predicate'; import hasItems from '../utils/has-items'; import ofType from '../utils/of-type'; import ofTypeDeep from '../utils/of-type-deep'; +import {partial, exact, Shape} from '../utils/match-shape'; + +export {Shape}; export class ObjectPredicate extends Predicate { /** @@ -132,4 +135,55 @@ export class ObjectPredicate extends Predicate { validator: (object: any) => keys.some(key => dotProp.has(object, key)) }); } + + /** + * Test an object to match the `shape` partially. This means that it ignores unexpected properties. The shape comparison is deep. + * + * The shape is an object which describes how the tested object should look like. The keys are the same as the source object and the values are predicates. + * + * @param shape Shape to test the object against. + * + * @example + * + * import ow from 'ow'; + * + * const object = { + * unicorn: '🦄', + * rainbow: '🌈' + * }; + * + * ow(object, ow.object.partialShape({ + * unicorn: ow.string + * })); + */ + partialShape(shape: Shape) { + return this.addValidator({ + // TODO: Improve this when message handling becomes smarter + message: (_, label, message) => `${message.replace('Expected', 'Expected property')} in ${label}`, + validator: object => partial(object, shape) + }); + } + + /** + * Test an object to match the `shape` exactly. This means that will fail if it comes across unexpected properties. The shape comparison is deep. + * + * The shape is an object which describes how the tested object should look like. The keys are the same as the source object and the values are predicates. + * + * @param shape Shape to test the object against. + * + * @example + * + * import ow from 'ow'; + * + * ow({unicorn: '🦄'}, ow.object.exactShape({ + * unicorn: ow.string + * })); + */ + exactShape(shape: Shape) { + return this.addValidator({ + // TODO: Improve this when message handling becomes smarter + message: (_, label, message) => `${message.replace('Expected', 'Expected property')} in ${label}`, + validator: object => exact(object, shape) + }); + } } diff --git a/source/lib/test.ts b/source/lib/test.ts new file mode 100644 index 0000000..0561bca --- /dev/null +++ b/source/lib/test.ts @@ -0,0 +1,13 @@ +import {testSymbol, BasePredicate} from './predicates/base-predicate'; + +/** + * Validate the value against the provided predicate. + * + * @hidden + * @param value Value to test. + * @param label Label which should be used in error messages. + * @param predicate Predicate to test to value against. + */ +export default function test(value: T, label: string | Function, predicate: BasePredicate) { + predicate[testSymbol](value, test, label); +} diff --git a/source/lib/utils/match-shape.ts b/source/lib/utils/match-shape.ts new file mode 100644 index 0000000..7e8b2a2 --- /dev/null +++ b/source/lib/utils/match-shape.ts @@ -0,0 +1,80 @@ +import is from '@sindresorhus/is'; +import {BasePredicate} from '../..'; +import test from '../test'; +import {isPredicate} from '../predicates/base-predicate'; + +export interface Shape { + [key: string]: BasePredicate | Shape; +} + +/** + * Test if the `object` matches the `shape` partially. + * + * @hidden + * @param object Object to test against the provided shape. + * @param shape Shape to test the object against. + * @param parent Name of the parent property. + */ +export function partial(object: {[key: string]: any; }, shape: Shape, parent?: string): boolean | string { + try { + for (const key of Object.keys(shape)) { + const label = parent ? `${parent}.${key}` : key; + + if (isPredicate(shape[key])) { + test(object[key], label, shape[key] as BasePredicate); + } else if (is.plainObject(shape[key])) { + const result = partial(object[key], shape[key] as Shape, label); + + if (result !== true) { + return result; + } + } + } + + return true; + } catch (error) { + return error.message; + } +} + +/** + * Test if the `object` matches the `shape` exactly. + * + * @hidden + * @param object Object to test against the provided shape. + * @param shape Shape to test the object against. + * @param parent Name of the parent property. + */ +export function exact(object: {[key: string]: any; }, shape: Shape, parent?: string): boolean | string { + try { + const objectKeys = new Set(Object.keys(object)); + + for (const key of Object.keys(shape)) { + objectKeys.delete(key); + + const label = parent ? `${parent}.${key}` : key; + + if (isPredicate(shape[key])) { + test(object[key], label, shape[key] as BasePredicate); + } else if (is.plainObject(shape[key])) { + const result = exact(object[key], shape[key] as Shape, label); + + if (result !== true) { + return result; + } + } + } + + if (objectKeys.size > 0) { + const key = Array.from(objectKeys.keys())[0]; + + const label = parent ? `${parent}.${key}` : key; + + return `Did not expect property \`${label}\` to exist, got \`${object[key]}\``; + } + + return true; + } catch (error) { + return error.message; + } +} diff --git a/source/predicates.ts b/source/predicates.ts index 9eea045..c3b9ebd 100644 --- a/source/predicates.ts +++ b/source/predicates.ts @@ -3,7 +3,7 @@ import {NumberPredicate} from './lib/predicates/number'; import {BooleanPredicate} from './lib/predicates/boolean'; import {Predicate, PredicateOptions} from './lib/predicates/predicate'; import {ArrayPredicate} from './lib/predicates/array'; -import {ObjectPredicate} from './lib/predicates/object'; +import {ObjectPredicate, Shape} from './lib/predicates/object'; import {DatePredicate} from './lib/predicates/date'; import {ErrorPredicate} from './lib/predicates/error'; import {MapPredicate} from './lib/predicates/map'; @@ -285,5 +285,6 @@ export { WeakMapPredicate, SetPredicate, WeakSetPredicate, - AnyPredicate + AnyPredicate, + Shape }; diff --git a/source/test/object.ts b/source/test/object.ts index 3f1b39a..e844641 100644 --- a/source/test/object.ts +++ b/source/test/object.ts @@ -205,3 +205,94 @@ test('object.hasAnyKeys', t => { ow({unicorn: '🦄'}, ow.object.hasAnyKeys('unicorn.value')); }, 'Expected object to have any key of `["unicorn.value"]`'); }); + +test('object.exactShape', t => { + t.notThrows(() => { + ow({unicorn: '🦄'}, ow.object.exactShape({ + unicorn: ow.string + })); + }); + + t.notThrows(() => { + ow({unicorn: '🦄'}, ow.object.exactShape({ + unicorn: ow.string + })); + }); + + t.notThrows(() => { + ow({unicorn: '🦄', rainbow: {value: '🌈'}}, ow.object.exactShape({ + unicorn: ow.string, + rainbow: { + value: ow.string + } + })); + }); + + t.throws(() => { + ow({unicorn: '🦄', rainbow: {value: '🌈'}}, ow.object.exactShape({ + unicorn: ow.string, + rainbow: { + foo: ow.string + } + })); + }, 'Expected property `rainbow.foo` to be of type `string` but received type `undefined` in object'); + + t.throws(() => { + ow({unicorn: '🦄', rainbow: '🌈'}, ow.object.exactShape({ + unicorn: ow.string + })); + }, 'Did not expect property `rainbow` to exist, got `🌈` in object'); + + const foo = {unicorn: '🦄', rainbow: {valid: true, value: '🌈'}}; + + t.throws(() => { + ow(foo, ow.object.exactShape({ + unicorn: ow.string, + rainbow: { + valid: ow.boolean + } + })); + }, 'Did not expect property `rainbow.value` to exist, got `🌈` in object `foo`'); +}); + +test('object.partialShape', t => { + t.notThrows(() => { + ow({unicorn: '🦄'}, ow.object.partialShape({ + unicorn: ow.string + })); + }); + + t.throws(() => { + ow({unicorn: '🦄'}, ow.object.partialShape({ + unicorn: ow.number + })); + }, 'Expected property `unicorn` to be of type `number` but received type `string` in object'); + + t.throws(() => { + ow({unicorn: '🦄', rainbow: {value: '🌈'}}, ow.object.partialShape({ + unicorn: ow.string, + rainbow: { + value: ow.number + } + })); + }, 'Expected property `rainbow.value` to be of type `number` but received type `string` in object'); + + t.throws(() => { + ow({unicorn: '🦄', rainbow: {rocket: {value: '🌈'}}}, ow.object.partialShape({ + unicorn: ow.string, + rainbow: { + rocket: { + value: ow.number + } + } + })); + }, 'Expected property `rainbow.rocket.value` to be of type `number` but received type `string` in object'); + + const foo = {unicorn: '🦄'}; + + t.throws(() => { + ow(foo, ow.object.partialShape({ + unicorn: ow.number + })); + }, 'Expected property `unicorn` to be of type `number` but received type `string` in object `foo`'); +});