UNPKG

narrow-minded

Version:

Easy typeof validations with sophisticated TypeScript inference.

541 lines (535 loc) 14.5 kB
/* 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