narrow-minded
Version:
Easy typeof validations with sophisticated TypeScript inference.
541 lines (535 loc) • 14.5 kB
JavaScript
/* eslint-enable @typescript-eslint/array-type */
/**
* Unique symbol that is used to decorate an array of Narrower schemas.
*/
const SOME = Symbol('SOME');
/**
* Decorates a narrower array to indicate narrowing should use the array as a
* set of options instead of asserting the value is an actual array.
*
* @example
* narrow(some('number'), 1) //=> true
* narrow({ optional: some('string', 'undefined') }), { optional: 'yep' }) //=> true
* narrow({ optional: some('string', 'undefined') }), {}) //=> true
*
* @param narrowers The Narrower sub-schemas that the value must be one of.
* @returns An array with the SOME symbol set to true.
*/
const some = (...narrowers) => {
return Object.assign(narrowers, {
[SOME]: true
});
};
/**
* Type guard for `NarrowerArr` type.
* @param n Narrower schema
* @returns true if `n` is an array and is _not_ `SOME` decorated
*/
const isNarrowerArr = n => Array.isArray(n) && !(SOME in n);
/**
* Type guard for `NarrowerSome` type.
* @param n Narrower schema
* @returns true if `n` is a `SOME` decorated array.
*/
const isNarrowerSome = n => Array.isArray(n) && SOME in n;
/**
* Type guard for `NarrowerObj` type
* @param n Narrower schema
* @returns true if `n` is an indexable object.
*/
const isNarrowerObj = n => isRecordObj(n);
/**
* Type guard for an indexable object
* @param u Any value
* @returns true if `u` is a non-array, non-null object.
*/
const isRecordObj = u => typeof u === 'object' && u !== null && !Array.isArray(u);
/**
* This function validates any value with `typeof` checks. Arrays and objects are traversed
* according to the Narrower structure. The boolean return value is also a TypeScript type
* predicate.
*
* **Objects** -
* All keys of `n` are checked against `u` and their narrow is validated if the key exists.
* Keys that are missing from `u` are treated as having the value `undefined`. This means
* you can use `{ key: some('undefined', ...)}` to allow for missing/optional keys.
*
* **Arrays** -
* Including multiple types in a Narrower array allows for mixed types. Each item in `u` must
* satisfy at least one of the types.
*
* **Null** -
* `typeof null` is `'object'` but null cannot have any keys. Use `{}` to match an object
* that is not null.
*
* @example
* // An array of mixed strings and numbers:
* narrow(['string', 'number'], [1, 'two']) //=> true
* narrow(['string', 'number'], [{}]) //=> false
*
* // Null:
* narrow('object', null) //=> true
* narrow({}, null) //=> false
*
* // A deep object:
* narrow({
* n: 'number',
* child: {
* word: 'string'
* },
* things: [
* ['number'],
* 'boolean'
* ],
* }, {
* n: 3.14,
* child: {
* word: 'Yes'
* },
* things: [
* false,
* [1, 2, 3],
* true
* ]
* }) //=> true
*
* @param n The Narrower schema.
* @param u The value of unknown type to validate.
* @returns A type predicate that `u` satisfies `n`.
*/
const narrow = (n, u) => {
return _narrow(n, u);
};
/**
* This does the actual value comparison based on the Narrower schema.
* It leaves out the fancy type inference.
* @private
* @param n The schema.
* @param u The value to validate.
* @returns Whether u matches n.
*/
const _narrow = (n, u) => {
if (typeof n === 'string') {
if (n === typeof u) {
return true;
} else {
return false;
}
}
if (Array.isArray(n)) {
if (SOME in n) {
return n.some(t => _narrow(t, u));
} else {
if (Array.isArray(u)) {
if (n.length === 0) {
// An empty schema array represents an array with unknown contents.
return true;
}
return u.every(v => n.some(t => _narrow(t, v)));
} else {
return false;
}
}
}
if (typeof u !== 'object' || u === null) {
return false;
}
const o = u;
return Object.entries(n).every(([k, t]) => _narrow(t, o[k]));
};
/**
* Creates a function from a narrower schema that can be reused to narrow objects.
* This simple closure can be used when a whole Guard instance would be too much.
*
* @example
* import { satisfier } from 'narrow-minded'
* const satisfies = satisfier(['string', 'number'])
* satisfies(['horse', 42]) // => true
*/
const satisfier = n => u => narrow(n, u);
class Guard {
/**
* Creates a new guard that uses a `narrow` function.
* A little shortcut for `new Guard(narrow(...))`.
* @example
*
* import { Guard } from 'narrow-minded'
* const myGuard = Guard.narrow(['string', 'number'])
* myGuard.satisfied(['horse', 42]) // => true
*
* @param n Narrower
* @returns Guard
*/
static narrow(n) {
return new Guard(u => narrow(n, u));
}
constructor(NF) {
this.NF = void 0;
this.NF = NF;
}
/**
* Runs the guard's narrowing function to validate the unknown value's type.
* Operates as a type predicate so conditional blocks infer this structure.
* @example
*
* const myGuard = Guard.narrow({
* name: 'string',
* values: ['number'],
* })
*
* const good: unknown = { name: 'Horse', values: [1, 2] }
* if (myGuard.satisfied(good)) {
* console.log('Good ' + good.name)
* // => 'Good Horse'
* }
*
* const bad: unknown = { name: 42, values: 'Nope' }
* if (!myGuard.satisfied(bad)) {
* console.log('Bad ')
* // => 'Bad'
* }
*
* @param u The unknown value.
* @returns A type predicate that `u` satisfies this guard.
*/
satisfied(u) {
return this.NF(u);
}
/**
* An identity function that returns the value passed to it. Useful for
* defining objects that satisfy this guard using type inference.
* @param p
* @returns p
*/
build(p) {
return p;
}
and(other) {
const left = this.NF;
const right = other instanceof Guard ? other.NF : other instanceof Function ? other : u => narrow(other, u);
return new Guard(u => left(u) && right(u));
}
}
/**
* A singleton that can be used to build `and` chains.
* @example
* if (unknown.and('string').satisfied('Great')) {
* console.log('Great')
* }
*/
const unknown = new Guard(_ => true);
const makeSubnodes = node => {
const {
value,
level
} = node;
if (typeof value !== 'object' || value === null) {
return [];
}
return Object.entries(value).map(([childProperty, childValue]) => ({
property: childProperty,
value: childValue,
level: level + 1,
parent: node
}));
};
const shouldContinue = visitResult => typeof visitResult === 'undefined' || Boolean(visitResult);
const DEFAULT_TRAVERSALS = {
depth: {
dequeue: q => q.pop(),
enqueue: (node, q, visitResult) => {
if (!shouldContinue(visitResult)) {
return;
}
const subnodes = makeSubnodes(node);
subnodes.reverse();
q.push(...subnodes);
}
},
breadth: {
dequeue: q => q.shift(),
enqueue: (node, q, visitResult) => {
if (!shouldContinue(visitResult)) {
return;
}
const subnodes = makeSubnodes(node);
q.push(...subnodes);
}
}
};
/**
* Generic tree traversal.
*/
const traverse = (root, {
visit,
dequeue,
enqueue
}) => {
const q = [{
property: '',
value: root,
level: 0,
parent: undefined
}];
while (q.length > 0) {
const node = dequeue(q);
const visitResult = visit(node);
enqueue(node, q, visitResult);
}
};
const traverseObjectDepthFirst = (root, visit) => traverse(root, {
visit,
dequeue: DEFAULT_TRAVERSALS.depth.dequeue,
enqueue: DEFAULT_TRAVERSALS.depth.enqueue
});
const traverseObjectBreadthFirst = (root, visit) => traverse(root, {
visit,
dequeue: DEFAULT_TRAVERSALS.breadth.dequeue,
enqueue: DEFAULT_TRAVERSALS.breadth.enqueue
});
/**
*
* @example
* // Given this failed narrow:
* narrow(
* {
* title: 'string',
* count: 'number',
* },
* {
* title: 10,
* count: 'bad boy',
* },
* )
* //=> false
*
* // The diff would be:
* diffNarrow(
* {
* title: 'string',
* count: 'number',
* },
* {
* title: 10,
* count: 'bad boy',
* },
* )
* //=>
* [
* {
* level: 1,
* property: 'title',
* expected: 'string',
* received: 10,
* },
* {
* level: 1,
* property: 'count',
* expected: 'number',
* received: 'bad boy',
* },
* ]
* @param n
* @param u
* @returns Array of `DiffResult` objects that indicate where `u` failed to satisfy `n`. An empty
* array is the same as `narrow(n, u)` returning true.
*/
const diffNarrow = (n, u) => {
const diffResults = [];
const root = {
expected: n,
received: u
};
traverse(root, {
visit: node => {
const {
value
} = node;
const {
expected,
received
} = value;
if (isShallowMatch(expected, received)) {
return true;
}
// If there is an ancestor some-arr that hasn't been traversed yet, then don't record a diff
// yet. Once we get to only 1 some-arr entry remaining, `findAncestorSome` will return
// nothing.
//
// This is called a second time within `enqueue` which is a performance hit. This could be
// restructured to only do the tree climb once.
if (findAncestorSome(node)) {
return false;
}
diffResults.push({
level: node.level,
property: node.property,
expected,
received
});
return false;
},
dequeue: q => q.pop(),
enqueue: (node, q, visitResult) => {
if (visitResult) {
const subnodes = makeDiffNodes(node);
subnodes.reverse();
q.push(...subnodes);
return;
}
const altAncestor = makeAltSomeAncestor(node);
if (altAncestor) {
q.push(altAncestor);
}
}
});
return diffResults;
};
/**
* Checks if `node` needs to be traversed and builds the required sub-nodes.
*/
const makeDiffNodes = node => {
const {
expected,
received
} = node.value;
if (isNarrowerObj(expected) && isRecordObj(received)) {
return Object.entries(expected).map(([keySub, expectedSub]) => {
const receivedSub = received[keySub];
return {
parent: node,
level: node.level + 1,
property: keySub,
value: {
expected: expectedSub,
received: receivedSub
}
};
});
}
if (isNarrowerArr(expected) && Array.isArray(received)) {
return received.map((receivedSub, receivedIdx) => {
const expectArrAsSome = some(...expected);
return {
parent: node,
level: node.level + 1,
property: receivedIdx.toString(),
value: {
expected: expectArrAsSome,
received: receivedSub
}
};
});
}
if (isNarrowerSome(expected)) {
// Every some-arr is shallow-matched against the first entry, so only enqueue a node for the
// first entry. If `visit` results in a mismatch down the this branch, `makeAltSomeAncestor`
// creates a node with that first branch removed.
return [{
parent: node,
level: node.level,
property: node.property,
value: {
expected: expected[0],
received
}
}];
}
return [];
};
/**
* First element is the input leaf. Last element is the highest ancestor (root).
*/
const listAncestors = leaf => {
const ancestors = [leaf];
while (true) {
const child = ancestors[0];
const parent = child == null ? void 0 : child.parent;
if (!parent) {
break;
}
ancestors.unshift(parent);
}
ancestors.reverse();
return ancestors;
};
/**
* Traverses the ancestors to find the lowest one that is a some-arr and has >1 entry. A some-arr with 1
* entry would be the ancestor that led to the current (unmatched) node. Includes the input leaf as
* the first element (if it has remaining entries).
*/
const findAncestorSome = leaf => {
const ancestors = listAncestors(leaf);
return ancestors.find(parent => {
const {
value: {
expected
}
} = parent;
return isNarrowerSome(expected) && expected.length > 1;
});
};
/**
* Searches the node's ancestors for the deepest some-node that has remaining alternative schemas.
* If found, the first some-entry is popped and a copy of the node with that (already checked) entry
* removed.
*/
const makeAltSomeAncestor = node => {
var _ancestorSomeNode$val, _ancestorSomeNode$val2;
const ancestorSomeNode = findAncestorSome(node);
const ancestorExpected = ancestorSomeNode != null && (_ancestorSomeNode$val = ancestorSomeNode.value) != null && _ancestorSomeNode$val.expected && isNarrowerSome(ancestorSomeNode == null || (_ancestorSomeNode$val2 = ancestorSomeNode.value) == null ? void 0 : _ancestorSomeNode$val2.expected) ? ancestorSomeNode.value.expected : undefined;
// This is the common case when there are no some-arr ancestors at all. It also comes up when all
// ancestor some-arrs have been exhausted (reduced to only 1 option).
if (!(ancestorSomeNode && ancestorExpected)) {
return undefined;
}
// Create a copy of the ancestor node with the first some-entry removed.
return {
parent: ancestorSomeNode.parent,
level: ancestorSomeNode.level,
property: ancestorSomeNode.property,
value: {
expected: some(...ancestorExpected.slice(1)),
received: ancestorSomeNode.value.received
}
};
};
const isShallowMatch = (n, u) => {
if (typeof n === 'string') {
return n === typeof u;
}
if (isNarrowerObj(n)) {
return isRecordObj(u);
}
if (isNarrowerArr(n)) {
return Array.isArray(u);
}
if (isNarrowerSome(n)) {
// An empty some-arr matches nothing.
if (n.length === 0) {
return false;
}
// A shallow match only checks the first entry.
const firstNSub = n[0];
return isShallowMatch(firstNSub, u);
}
return false;
};
exports.DEFAULT_TRAVERSALS = DEFAULT_TRAVERSALS;
exports.Guard = Guard;
exports.SOME = SOME;
exports.diffNarrow = diffNarrow;
exports.isNarrowerArr = isNarrowerArr;
exports.isNarrowerObj = isNarrowerObj;
exports.isNarrowerSome = isNarrowerSome;
exports.isRecordObj = isRecordObj;
exports.makeSubnodes = makeSubnodes;
exports.narrow = narrow;
exports.satisfier = satisfier;
exports.shouldContinue = shouldContinue;
exports.some = some;
exports.traverse = traverse;
exports.traverseObjectBreadthFirst = traverseObjectBreadthFirst;
exports.traverseObjectDepthFirst = traverseObjectDepthFirst;
exports.unknown = unknown;
//# sourceMappingURL=index.cjs.map