Browse Source

Add object shape validators (#127)

string-allowed-chars
Sam Verschueren 6 years ago
committed by Sindre Sorhus
parent
commit
87e4ec2edd
  1. 21
      readme.md
  2. 10
      source/index.ts
  3. 54
      source/lib/predicates/object.ts
  4. 13
      source/lib/test.ts
  5. 80
      source/lib/utils/match-shape.ts
  6. 5
      source/predicates.ts
  7. 91
      source/test/object.ts

21
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

10
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<T>(label: string, predicate: BasePredicate<T>): (value: T) => void;
}
const test = <T>(value: T, label: string | Function, predicate: BasePredicate<T>) => {
predicate[testSymbol](value, test, label);
};
const ow = <T>(value: T, labelOrPredicate: any, predicate?: BasePredicate<T>) => {
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';

54
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<object> {
/**
@ -132,4 +135,55 @@ export class ObjectPredicate extends Predicate<object> {
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)
});
}
}

13
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<T>(value: T, label: string | Function, predicate: BasePredicate<T>) {
predicate[testSymbol](value, test, label);
}

80
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<string>(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;
}
}

5
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
};

91
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`');
});

Loading…
Cancel
Save