object-shape-tester
Version:
Test object properties and value types.
181 lines (180 loc) • 5.72 kB
JavaScript
import { check } from '@augment-vir/assert';
import { filterMap, removeDuplicates } from '@augment-vir/common';
import { Kind, Type, TypeRegistry, } from '@sinclair/typebox';
import { registerErrorMessage } from '../errors/error-message.js';
import { checkValidShape } from '../shape/check-shape.js';
import { defineShape, isSchema, isShape } from '../shape/shape.js';
import { enumShape } from './enum.shape.js';
import { exactShape } from './exact.shape.js';
import { unionShape } from './union.shape.js';
/**
* Kind for {@link recordShape}.
*
* @category Internal
*/
export const recordShapeKind = 'recordShape';
/**
* Creates a shape that requires an object with certain keys and values.
*
* @category Shape
* @example
*
* ```ts
* import {recordShape, checkValidShape} from 'object-shape-tester';
*
* const myShape = recordShape({
* keys: '',
* values: -1,
* });
*
* checkValidShape({a: 0}, myShape); // `true`
* checkValidShape({a: '0'}, myShape); // `false`
* ```
*/
export function recordShape({ keys, values, partial, additionalProperties, }) {
setRecordShapeRegistry();
const keysShape = defineKeysShape(keys);
const valuesShape = defineShape(values);
return Type.Unsafe({
[Kind]: recordShapeKind,
keysShape,
valuesShape,
isPartial: !!partial,
additionalProperties: !!additionalProperties,
default: createDefaultValue({
isPartial: !!partial,
keysShape,
valuesShape,
}),
});
}
function setRecordShapeRegistry() {
/* node:coverage disable: this package transitively depends on itself so this gets executed outside of the coverage calculator. */
if (!TypeRegistry.Has(recordShapeKind)) {
TypeRegistry.Set(recordShapeKind, (options, value) => {
if (typeof value !== 'object' || !value || Array.isArray(value)) {
return false;
}
const existingKeysMatch = Object.entries(value).every(([key, value,]) => {
const matchesKey = options.additionalProperties
? true
: checkValidShape(key, options.keysShape);
const matchesValue = checkValidShape(value, options.valuesShape);
return matchesKey && matchesValue;
});
const hasAllKeys = options.isPartial
? true
: !getMissingKeys(options.keysShape, value).length;
return existingKeysMatch && hasAllKeys;
});
}
/* node:coverage enable */
registerErrorMessage(recordShapeKind, (error) => {
const schema = error.schema;
const options = schema;
const value = error.value;
if (typeof value !== 'object' || !value || Array.isArray(value)) {
return 'Expected an object';
}
const wrongExistingKeys = filterMap(Object.entries(value), ([key]) => key, (mappedKey, [key, value,]) => {
return (!checkValidShape(key, options.keysShape) ||
!checkValidShape(value, options.valuesShape));
});
const missingKeys = getMissingKeys(options.keysShape, value);
const wrongKeysString = wrongExistingKeys.length
? [
'Failure at keys',
wrongExistingKeys.join(','),
].join(': ')
: '';
const missingKeysString = missingKeys.length
? [
'Missing keys',
missingKeys.join(','),
].join(': ')
: '';
return [
wrongKeysString,
missingKeysString,
]
.filter(check.isTruthy)
.join('\n');
});
}
function getMissingKeys(keysShape, input) {
const finiteKeys = extractFiniteKeys(keysShape).filter((entry) => check.isPropertyKey(entry));
if (!finiteKeys.length) {
return [];
}
return finiteKeys.filter((key) => !check.hasKey(input, key));
}
function createDefaultValue({ keysShape, valuesShape, isPartial, }) {
if (isPartial) {
return {};
}
else {
const finiteKeys = extractFiniteKeys(keysShape);
const defaultValue = valuesShape.default;
return Object.fromEntries(finiteKeys.map((key) => [
key,
defaultValue,
]));
}
}
/**
* Converts a keys requirement to a {@link Shape}.
*
* @category Internal
*/
export function defineKeysShape(keys) {
if (isShape(keys)) {
return keys;
}
else if (isSchema(keys)) {
return defineShape(keys);
}
else if (check.isObject(keys)) {
return enumShape(keys);
}
else if (check.isArray(keys) && check.isLengthAtLeast(keys, 1)) {
return unionShape(...keys.map((key) => exactShape(key)));
}
else if (check.isPropertyKey(keys)) {
return defineShape(keys);
}
else {
return defineShape(Type.Undefined());
}
}
/**
* Extracts all finite keys, if any, that match the given keys {@link Shape}.
*
* @category Internal
*/
export function extractFiniteKeys(keys) {
const schema = keys.$_schema;
const kind = schema[Kind].toLowerCase();
if ([
'const',
'literal',
].includes(kind)) {
return [
schema.const,
];
}
else if (kind === 'union') {
return removeDuplicates(schema.anyOf.flatMap((subSchema) => extractFiniteKeys(defineShape(subSchema))));
}
else if ([
'undefined',
'number',
'string',
'symbol',
].includes(kind)) {
/** Not a finite set of keys. */
return [];
}
else {
return extractFiniteKeys(defineKeysShape(keys.default));
}
}