UNPKG

object-shape-tester

Version:
726 lines (725 loc) 25.7 kB
import { check } from '@augment-vir/assert'; import { combineErrors, ensureError, ensureErrorAndPrependMessage, extractErrorMessage, getObjectTypedKeys, getObjectTypedValues, mapObjectValues, stringify, wrapInTry, } from '@augment-vir/common'; import { isCustomSpecifier } from '../define-shape/custom-specifier.js'; import { getShapeSpecifier, indexedKeys, isAndShapeSpecifier, isClassShapeSpecifier, isEnumShapeSpecifier, isExactShapeSpecifier, isIndexedKeysSpecifier, isNumericRangeShapeSpecifier, isOptionalShapeSpecifier, isOrShapeSpecifier, isShapeDefinition, isTupleShapeSpecifier, isUnknownShapeSpecifier, } from '../define-shape/shape-specifiers.js'; import { haveEqualTypes } from '../define-shape/type-equality.js'; import { ShapeMismatchError } from '../errors/shape-mismatch.error.js'; /** * Check if a variable matches the given shape. * * @category Main * @example * * ```ts * import {defineShape, isValidShape} from 'object-shape-tester'; * * const myShape = defineShape({ * a: '', * }); * * isValidShape({a: 'hi'}, myShape); // `true` * isValidShape({a: 3}, myShape); // `false` * isValidShape({a: 'hi', b: 'bye'}, {allowExtraKeys: true}, myShape); // `true` * ``` * * @returns `true` or `false` */ export function isValidShape(subject, shapeDefinition, options = {}) { try { assertValidShape(subject, shapeDefinition, options); return true; } catch { return false; } } /** * Check if a variable matches the given shape. Returns the variable if it matches, otherwise * returns `undefined` * * @category Main * @example * * ```ts * import {defineShape, checkWrapValidShape} from 'object-shape-tester'; * * const myShape = defineShape({ * a: '', * }); * * checkWrapValidShape({a: 'hi'}, myShape); // returns `{a: 'hi'}` * checkWrapValidShape({a: 3}, myShape); // returns `undefined` * checkWrapValidShape({a: 'hi', b: 'bye'}, {allowExtraKeys: true}, myShape); // returns `{a: 'hi', b: 'bye'}` * ``` * * @returns `true` or `false` */ export function checkWrapValidShape(subject, shapeDefinition, options = {}) { if (isValidShape(subject, shapeDefinition, options)) { return subject; } else { return undefined; } } /** * Assets that a variable matches the given shape and then returns the variable. * * @category Main * @example * * ```ts * import {defineShape, assertWrapValidShape} from 'object-shape-tester'; * * const myShape = defineShape({ * a: '', * }); * * assertValidShape({a: 'hi'}, myShape); // returns `{a: 'hi'}` * assertValidShape({a: 'hi', b: 'bye'}, myShape, {allowExtraKeys: true}); // returns `{a: 'hi', b: 'bye'}` * assertValidShape({a: 3, myShape}); // throws an error * ``` * * @throws {@link ShapeMismatchError} If there is a mismatch */ export function assertWrapValidShape(subject, shapeDefinition, options = {}, failureMessage = '') { assertValidShape(subject, shapeDefinition, options, failureMessage); return subject; } /** * Assets that a variable matches the given shape. * * @category Main * @example * * ```ts * import {defineShape, assertValidShape} from 'object-shape-tester'; * * const myShape = defineShape({ * a: '', * }); * * assertValidShape({a: 'hi'}, myShape); // succeeds * assertValidShape({a: 'hi', b: 'bye'}, myShape, {allowExtraKeys: true}); // succeeds * assertValidShape({a: 3, myShape}); // fails * ``` * * @throws {@link ShapeMismatchError} If there is a mismatch */ export function assertValidShape(subject, shapeDefinition, options = {}, failureMessage = '') { try { internalAssertValidShape({ subject, shape: shapeDefinition.shape, keys: ['top level'], options: { exactValues: false, ignoreExtraKeys: !!options.allowExtraKeys, }, }); } catch (error) { if (failureMessage) { throw ensureErrorAndPrependMessage(error, failureMessage); } else { throw error; } } } function createKeyString(keys) { return [ keys[0], ...keys.slice(1).map((key) => `'${String(key)}'`), ].join(' -> '); } function internalAssertValidShape({ subject, shape, keys, options, }) { // unknown shape specifier allows anything, abort instantly if (isUnknownShapeSpecifier(shape)) { return true; } else if (isShapeDefinition(shape)) { return internalAssertValidShape({ subject, shape: shape.shape, keys, options }); } else if (isCustomSpecifier(shape)) { if (!shape.checker(subject)) { throw new ShapeMismatchError(`Subject ${stringify(subject)} does not match ${shape.customName} shape.`); } return true; } const keysString = createKeyString(keys); const subjectAsSpecifier = getShapeSpecifier(subject); if (subjectAsSpecifier) { throw new ShapeMismatchError(`Shape test subjects cannot be contain shape specifiers but one was found at ${keysString}.`); } else if (isTupleShapeSpecifier(shape)) { if (!check.isArray(subject)) { throw new ShapeMismatchError(`Subject is not an array and cannot match tuple definition at key ${keysString}`); } return shape.parts.every((shapeEntry, index) => { const subjectValue = subject[index]; return internalAssertValidShape({ keys: [ ...keys, index, ], options, shape: shapeEntry, subject: subjectValue, }); }); } else if (isOptionalShapeSpecifier(shape)) { /** * The optional specifier does not add any extra restrictions when the subject actually * exists. Thus, we'll just compare the subject to the optional shape's input. */ return internalAssertValidShape({ keys, options, shape: shape.parts[0], subject, }); } const error = wrapInTry(() => { matchesShape(subject, shape, keys, { ...options, throw: true }); }); if (error) { throw new ShapeMismatchError(`Shape mismatch at ${keysString}: ${extractErrorMessage(error)}`); } if (check.isFunction(shape)) { return check.isFunction(subject); } else if (isClassShapeSpecifier(shape)) { return subject instanceof shape.parts[0]; } else if (subject && typeof subject === 'object') { const objectSubject = subject; const keysPassed = options.ignoreExtraKeys ? {} : Object.fromEntries(Object.keys(objectSubject).map((key) => [ key, false, ])); const errors = []; let matched = false; if (isOrShapeSpecifier(shape)) { const orErrors = []; matched = shape.parts.some((shapePart) => { try { const newKeysPassed = internalAssertValidShape({ subject, shape: shapePart, keys, options, }); Object.assign(keysPassed, newKeysPassed); return true; /* node:coverage ignore next 14 */ /** Cover the edge case of errors. */ } catch (error) { if (error instanceof ShapeMismatchError) { orErrors.push(error); return false; } else { throw error; } } }); if (!matched && check.isLengthAtLeast(orErrors, 1)) { errors.push(orErrors[0]); } } else if (isAndShapeSpecifier(shape)) { matched = shape.parts.every((shapePart) => { try { const newPassedKeys = internalAssertValidShape({ subject, shape: shapePart, keys, options: { ...options, ignoreExtraKeys: true, }, }); Object.assign(keysPassed, newPassedKeys); return true; /* node:coverage ignore next 9 */ /** Cover the edge case of errors. */ } catch (error) { if (error instanceof ShapeMismatchError) { errors.push(error); return false; } else { throw error; } } }); } else if (isExactShapeSpecifier(shape)) { const newKeysPassed = internalAssertValidShape({ subject, shape: shape.parts[0], keys, options: { ...options, exactValues: true, }, }); Object.assign(keysPassed, newKeysPassed); matched = true; /* node:coverage ignore next 8 */ } else if (isEnumShapeSpecifier(shape)) { /** * Technically this case should never get triggered because oft he earlier * `!matchesShape` check. */ throw new ShapeMismatchError(`Cannot compare an enum specifier to an object at ${keysString}`); } else if (check.isArray(shape) && check.isArray(objectSubject)) { // special case arrays matched = objectSubject.every((subjectEntry, index) => { const passed = shape.some((shapeEntry) => { try { internalAssertValidShape({ subject: subjectEntry, shape: shapeEntry, keys: [ ...keys, index, ], options, }); return true; } catch (error) { if (error instanceof ShapeMismatchError) { errors.push(error); return false; /* v8 ignore next 3: edge case catch for internal errors*/ } else { throw error; } } }); keysPassed[index] = passed; return passed; }); } else if (isIndexedKeysSpecifier(shape)) { const newKeysPassed = mapObjectValues(subject, (key, value) => { if (!options.ignoreExtraKeys) { internalAssertValidShape({ shape: shape.parts[0].keys, subject: key, keys: [ ...keys, key, ], options, }); } internalAssertValidShape({ shape: shape.parts[0].values, subject: value, keys: [ ...keys, key, ], options, }); return true; }); Object.assign(keysPassed, newKeysPassed); matched = true; } else { /** If we have no specifier, check the whole object. */ const newKeysPassed = isValidRawObjectShape({ keys, options, shape, subject, }); Object.assign(keysPassed, newKeysPassed); matched = true; } if (errors.length) { throw new ShapeMismatchError(extractErrorMessage(combineErrors(errors)), { cause: errors[0], }); } /* node:coverage ignore next 15 */ /** This might not actually be necessary anymore, I can't get it to trigger in tests. */ if (!matched) { const failedKeys = Object.keys(keysPassed).filter((key) => { return !keysPassed[key]; }); const errorMessage = `Failed on key(s): ${failedKeys .map((failedKey) => createKeyString([ ...keys, failedKey, ])) .join(',')}`; throw new ShapeMismatchError(errorMessage); } if (!options.ignoreExtraKeys) { Object.entries(keysPassed).forEach(([key, wasTested,]) => { if (!wasTested) { throw new ShapeMismatchError(`subject as extra key '${key}' in ${keysString}.`); } }); } return keysPassed; } else if (options.exactValues) { return subject === shape; } return true; } function isValidRawObjectShape({ keys, options, shape, subject, }) { const keysString = createKeyString(keys); const keysPassed = {}; if (check.isObject(shape)) { const shapeKeys = new Set(getObjectTypedKeys(shape)); const subjectKeys = new Set(getObjectTypedKeys(subject)); shapeKeys.forEach((shapeKey) => { if ( /** Account for non-enumerable keys. */ shapeKey in subject || /** Account for optional keys. */ isOptionalShapeSpecifier(shape[shapeKey])) { subjectKeys.add(shapeKey); } }); if (!options.ignoreExtraKeys) { subjectKeys.forEach((subjectKey) => { if (!shapeKeys.has(subjectKey)) { throw new ShapeMismatchError(`Subject has extra key '${String(subjectKey)}' in ${keysString}`); } }); } shapeKeys.forEach((shapePartKey) => { const shapeValue = shape[shapePartKey]; const orContainsUndefined = isOrShapeSpecifier(shapeValue) ? shapeValue.parts.includes(undefined) : false; const containsUndefined = shapeValue?.includes?.(undefined) || shapeValue === undefined; if (!subjectKeys.has(shapePartKey) && !orContainsUndefined && !containsUndefined) { throw new ShapeMismatchError(`Subject missing key '${String(shapePartKey)}' in ${keysString}`); } }); subjectKeys.forEach((key) => { /** If the key doesn't exist and it's optional, mark it as passed. */ if (!(key in subject) && isOptionalShapeSpecifier(shape[key])) { keysPassed[key] = true; return; } const subjectChild = subject[key]; if (options.ignoreExtraKeys && !shapeKeys.has(key)) { return; } const shapePartChild = shape[key]; internalAssertValidShape({ subject: subjectChild, shape: shapePartChild, keys: [ ...keys, key, ], options, }); keysPassed[key] = true; }); /* v8 ignore next 3: edge case handling */ } else { throw new ShapeMismatchError(`Shape definition at ${keysString} was not an object.`); } return keysPassed; } /** * Checks if the given `subject` matches the given `shape`. * * @category Internal */ export function matchesShape(subject, shape, keys, options, checkValues) { const specifier = getShapeSpecifier(shape); if (specifier) { if (isCustomSpecifier(specifier)) { if (specifier.checker(subject)) { return true; } else if (options.throw) { throw new ShapeMismatchError(`Failed to match '${specifier.customName}'`); } else { return false; } } else if (isNumericRangeShapeSpecifier(specifier)) { if (!check.isNumber(subject)) { if (options.throw) { throw new ShapeMismatchError('Invalid number'); } return false; } if (subject >= specifier.parts[0] && subject <= specifier.parts[1]) { return true; } else if (options.throw) { throw new ShapeMismatchError(`${subject} is not in range of '${stringify(specifier.parts)}'`); } else { return false; } } else if (isClassShapeSpecifier(specifier)) { if (subject instanceof specifier.parts[0]) { return true; } else if (options.throw) { throw new ShapeMismatchError(`not an instance of '${specifier.parts[0].name}'`); } else { return false; } } else if (isAndShapeSpecifier(specifier)) { return specifier.parts.every((part, index) => { try { internalAssertValidShape({ subject, shape: part, keys, options: { ...options, ignoreExtraKeys: true, }, }); return true; } catch (error) { if (options.throw) { throw new ShapeMismatchError(`Failed on 'and' at ${index}: ${extractErrorMessage(error)}`); } else { return false; } } }); } else if (isOrShapeSpecifier(specifier)) { const orErrors = []; if (specifier.parts.some((part) => { try { internalAssertValidShape({ subject, shape: part, keys, options }); return true; } catch (error) { orErrors.push(ensureError(error)); return false; } })) { return true; } else if (options.throw) { throw new ShapeMismatchError(`Failed all ors: ${extractErrorMessage(combineErrors(orErrors))}`); } else { return false; } } else if (isExactShapeSpecifier(specifier)) { if (check.isObject(subject)) { try { internalAssertValidShape({ subject, shape: specifier.parts[0], keys, options: { ...options, exactValues: true, }, }); return true; } catch (error) { if (options.throw) { throw error; } else { return false; } } } else if (subject === specifier.parts[0]) { return true; } else if (options.throw) { throw new ShapeMismatchError(`Does not exactly match '${stringify(specifier.parts[0])}'`); } else { return false; } } else if (isEnumShapeSpecifier(specifier)) { if (check.hasValue(Object.values(specifier.parts[0]), subject)) { return true; } else if (options.throw) { throw new ShapeMismatchError(`Failed to match any enum values in ${Object.values(specifier.parts[0]).join(',')}`); } else { return true; } } else if (isIndexedKeysSpecifier(specifier)) { if (!check.isObject(subject)) { if (options.throw) { throw new ShapeMismatchError('Not an object.'); } else { return false; } } const matchesKeys = matchesIndexedKeysSpecifierKeys(subject, specifier, !!options.ignoreExtraKeys); const matchesValues = getObjectTypedValues(subject).every((subjectValue) => { try { internalAssertValidShape({ subject: subjectValue, shape: specifier.parts[0].values, keys, options, }); return true; } catch (error) { if (options.throw) { throw error; } else { return false; } } }); if (matchesKeys && matchesValues) { return true; } else if (options.throw) { throw new ShapeMismatchError('Failed indexed keys.'); } else { return false; } } else if (isUnknownShapeSpecifier(specifier)) { return true; } } if (checkValues) { if (shape === subject) { return true; } else if (options.throw) { throw new ShapeMismatchError(`${stringify(subject)} does not equal ${stringify(shape)}`); } else { return false; } } else if (haveEqualTypes({ subject, shape })) { return true; } else if (options.throw) { throw new ShapeMismatchError(`${stringify(subject)} does not have the same type as ${stringify(shape)}`); } else { return false; } } function matchesIndexedKeysSpecifierKeys(subject, specifier, ignoreExtraKeys) { const required = specifier.parts[0].required; const keys = specifier.parts[0].keys; const allRequiredKeys = expandIndexedKeysKeys(specifier); if (check.isBoolean(allRequiredKeys)) { return getObjectTypedKeys(subject).every((subjectKey) => { return matchesShape(subjectKey, keys, [], { exactValues: false, ignoreExtraKeys }); }); } const matchesRequiredKeys = required ? allRequiredKeys.every((requiredKey) => { return getObjectTypedKeys(subject).some((subjectKey) => matchesShape(subjectKey, requiredKey, [], { exactValues: false, ignoreExtraKeys: false, }, true)); }) : true; const matchesExistingKeys = getObjectTypedKeys(subject).every((subjectKey) => { const isExpectedKey = allRequiredKeys.includes(subjectKey); if (isExpectedKey) { return matchesShape(subjectKey, keys, [], { exactValues: false, ignoreExtraKeys: false }); } else { return ignoreExtraKeys; } }); return matchesExistingKeys && matchesRequiredKeys; } /** * Expands an {@link indexedKeys} shape part into an array of its valid keys. * * @category Internal * @returns `true` if any keys are allowed. `false` if a bounded set of keys cannot be determined. * `PropertyKey[]` if there's a specific set of keys that can be extracted. */ export function expandIndexedKeysKeys(specifier) { const keys = specifier.parts[0].keys; const nestedSpecifier = getShapeSpecifier(keys); if (check.isPropertyKey(keys)) { return true; } else if (nestedSpecifier) { if (isClassShapeSpecifier(nestedSpecifier)) { return false; } else if (isAndShapeSpecifier(nestedSpecifier)) { return false; } else if (isOrShapeSpecifier(nestedSpecifier)) { const nestedPropertyKeys = nestedSpecifier.parts.map((part) => { return expandIndexedKeysKeys(indexedKeys({ ...specifier.parts[0], keys: part, })); }); if (nestedPropertyKeys.includes(false)) { return false; } return nestedPropertyKeys.flat().filter(check.isPropertyKey); } else if (isExactShapeSpecifier(nestedSpecifier)) { const propertyKeyParts = nestedSpecifier.parts.filter(check.isPropertyKey); if (propertyKeyParts.length !== nestedSpecifier.parts.length) { return false; } return propertyKeyParts; } else if (isEnumShapeSpecifier(nestedSpecifier)) { return Object.values(nestedSpecifier.parts[0]); } else if (isIndexedKeysSpecifier(nestedSpecifier)) { return false; } else if (isUnknownShapeSpecifier(nestedSpecifier)) { return true; } } return false; }