object-shape-tester
Version:
Test object properties and value types.
171 lines (170 loc) • 5.57 kB
JavaScript
import { assert, check } from '@augment-vir/assert';
import { stringify } from '@augment-vir/common';
import { Kind, Type, } from '@sinclair/typebox';
import { TypeCompiler } from '@sinclair/typebox/compiler';
import { setShapeDefinitionErrorMessage } from '../errors/error-message.js';
/**
* A special key string which is used to tag {@link Shape} instances so that we know they're shapes
* instead of part of the shape itself.
*
* We don't use `instanceof` with a Class constructor for this because that breaks when you have
* multiple versions of object-shape-tester defining and consuming shapes.
*
* @category Internal
*/
export const shapeIdentifier = Symbol.for('object-shape-tester.shape-identifier');
/**
* Defines a shape from the given init.
*
* @category Define
*/
export function defineShape(init) {
setShapeDefinitionErrorMessage();
if (isShape(init)) {
/**
* If `init` is already a {@link Shape}, don't construct a new {@link Shape} instance, just
* return the original one.
*/
return init;
}
const schema = shapeInitToSchema(init);
const schemaNoExtraKeys = forceAdditionalProperties(schema, false);
const schemaExtraKeys = forceAdditionalProperties(schema, true);
const shape = {
$_schema: schema,
$_schemaNoExtraKeys: schemaNoExtraKeys,
$_schemaExtraKeys: schemaExtraKeys,
default: schema.default,
$_compiledSchema: TypeCompiler.Compile(schema),
$_compiledSchemaNoExtraKeys: TypeCompiler.Compile(schemaNoExtraKeys),
$_compiledSchemaExtraKeys: TypeCompiler.Compile(schemaExtraKeys),
};
Object.defineProperties(shape, {
runtimeType: {
configurable: false,
enumerable: false,
get() {
throw new Error(`runtimeType cannot be used as a value, it is only for types.`);
},
},
[shapeIdentifier]: {
configurable: false,
enumerable: false,
writable: false,
value: true,
},
});
return shape;
}
/**
* Checks if `input` is a Shape.
*
* @category Internal
*/
export function isShape(input) {
return check.hasKey(input, shapeIdentifier) && !!input[shapeIdentifier];
}
/**
* Checks if `input` is a TSchema.
*
* @category Internal
*/
export function isSchema(input) {
return check.hasKey(input, Kind);
}
/**
* Creates a copy of the given schema and applies `additionalProperties: false` to all object
* schemas contained within.
*
* @category Internal
*/
function forceAdditionalProperties(current, forcedValue) {
const clone = { ...current };
if (Array.isArray(current.anyOf)) {
clone.anyOf = current.anyOf.map((entry) => forceAdditionalProperties(entry, forcedValue));
}
if (Array.isArray(current.allOf)) {
clone.allOf = current.allOf.map((entry) => forceAdditionalProperties(entry, forcedValue));
}
if (isSchema(current.items)) {
clone.items = forceAdditionalProperties(current.items, forcedValue);
}
else if (Array.isArray(current.items)) {
clone.items = current.items.map((entry) => forceAdditionalProperties(entry, forcedValue));
}
if (check.isObject(current.properties)) {
const newProps = {};
Object.entries(current.properties).forEach(([key, value,]) => {
newProps[key] = forceAdditionalProperties(value, forcedValue);
});
clone.properties = newProps;
}
clone.additionalProperties = forcedValue;
return clone;
}
/**
* Converts the shape init to a TSchema.
*
* @category Internal
*/
export function shapeInitToSchema(init) {
if (isSchema(init)) {
return init;
}
else if (isShape(init)) {
return init.$_schema;
}
else if (check.isFunction(init)) {
return Type.Function([], Type.Any(), {
default: init,
});
}
else if (check.isObject(init)) {
const objectDefault = {};
const objectType = {};
Object.entries(init).forEach(([key, value,]) => {
const valueSchema = shapeInitToSchema(value);
objectType[key] = valueSchema;
objectDefault[key] = valueSchema.default;
});
return Type.Object(objectType, {
default: objectDefault,
});
}
else if (check.isArray(init)) {
return Type.Array(Type.Union(init.map((entry) => shapeInitToSchema(entry))), {
default: [],
});
}
else if (check.isPrimitive(init)) {
if (check.isString(init)) {
return Type.String({ default: init });
}
else if (check.isNumber(init)) {
return Type.Number({ default: init });
}
else if (check.isBoolean(init)) {
return Type.Boolean({ default: init });
}
else if (check.isSymbol(init)) {
return Type.Symbol({ default: init });
}
else if (check.isNull(init)) {
return Type.Null({ default: null });
}
else if (check.isUndefined(init)) {
return Type.Undefined({ default: undefined });
}
else if (check.isBigInt(init)) {
return Type.BigInt({ default: init });
/* node:coverage ignore next 7 */
}
else {
assert.tsType(init).equals();
assert.never(`Unexpected primitive shape value type: '${typeof init}'`);
}
}
else {
throw new Error(`Invalid shape: ${stringify(init)}`);
}
}