UNPKG

@travetto/schema

Version:

Data type registry for runtime validation, reflection and binding.

276 lines (259 loc) 9.68 kB
import { isNumberObject, isBooleanObject, isStringObject } from 'node:util/types'; import { asConstructable, castTo, type Class, asFull, TypedObject } from '@travetto/runtime'; import { UnknownType } from './types.ts'; const REGEX_PATTERN = /[\/](.*)[\/](i|g|m|s)?/; /** * Utilities for data conversion and binding */ export class DataUtil { /** * Is a value a plain JS object, created using {} * @param value Object to check */ static isPlainObject(value: unknown): value is Record<string, unknown> { return typeof value === 'object' // separate from primitives && value !== undefined && value !== null // is obvious && value.constructor === Object // separate instances (Array, DOM, ...) && Object.prototype.toString.call(value) === '[object Object]'; // separate build-in like Math } /** * Is a value of primitive type * @param value Value to check */ static isPrimitive(value: unknown): value is (string | boolean | number | RegExp) { switch (typeof value) { case 'string': case 'boolean': case 'number': case 'bigint': return true; case 'object': return !!value && ( value instanceof RegExp || value instanceof Date || isStringObject(value) || isNumberObject(value) || isBooleanObject(value) ); default: return false; } } /** * Is simple, as a primitive, function or class */ static isSimpleValue(value: unknown): value is Function | Class | string | number | RegExp | Date { return this.isPrimitive(value) || typeof value === 'function'; } static #deepAssignRaw(a: unknown, b: unknown, mode: 'replace' | 'loose' | 'strict' | 'coerce' = 'loose'): unknown { const isEmptyA = a === undefined || a === null; const isEmptyB = b === undefined || b === null; const isArrA = Array.isArray(a); const isArrB = Array.isArray(b); const isSimpA = !isEmptyA && this.isSimpleValue(a); const isSimpB = !isEmptyB && this.isSimpleValue(b); let value: unknown; if (isEmptyA || isEmptyB) { // If no `a`, `b` always wins if (mode === 'replace' || b === null || !isEmptyB) { value = isEmptyB ? b : this.shallowClone(b); } else if (!isEmptyA) { value = this.shallowClone(a); } else { value = undefined; } } else { if (isArrA !== isArrB || isSimpA !== isSimpB) { throw new Error(`Cannot merge differing types ${a} and ${b}`); } if (Array.isArray(b)) { // Arrays value = a; // Write onto A if (mode === 'replace') { value = b; } else { const valueArray: unknown[] = castTo(value); const bArray = b; for (let i = 0; i < bArray.length; i++) { valueArray[i] = this.#deepAssignRaw(valueArray[i], bArray[i], mode); } } } else if (isSimpB) { // Scalars const match = typeof a === typeof b; value = b; if (!match) { // If types do not match if (mode === 'strict') { // Bail on strict throw new Error(`Cannot merge ${a} [${typeof a}] with ${b} [${typeof b}]`); } else if (mode === 'coerce') { // Force on coerce value = this.coerceType(b, asConstructable(a).constructor, false); } } } else { // Object merge value = a; const bObject: Record<string, unknown> = castTo(b); const valueObject: Record<string, unknown> = castTo(value); for (const key of Object.keys(bObject)) { if (key === '__proto__' || key === 'constructor' || key === 'prototype') { continue; } valueObject[key] = this.#deepAssignRaw(valueObject[key], bObject[key], mode); } } } return value; } /** * Create regex from string, including flags * @param input Convert input to a regex */ static toRegex(input: string | RegExp): RegExp { if (input instanceof RegExp) { return input; } else if (REGEX_PATTERN.test(input)) { const [, pattern, module] = input.match(REGEX_PATTERN) ?? []; return new RegExp(pattern, module); } else { return new RegExp(input); } } /** * Coerce an input of any type to the class provided * @param input Input value * @param type Class to coerce to (String, Boolean, Number, Date, RegExp, Object) * @param strict Should a failure to coerce throw an error? */ static coerceType(input: unknown, type: typeof String, strict?: boolean): string; static coerceType(input: unknown, type: typeof Number, strict?: boolean): number; static coerceType(input: unknown, type: typeof BigInt, strict?: boolean): bigint; static coerceType(input: unknown, type: typeof Boolean, strict?: boolean): boolean; static coerceType(input: unknown, type: typeof Date, strict?: boolean): Date; static coerceType(input: unknown, type: typeof RegExp, strict?: boolean): RegExp; static coerceType(input: unknown, type: typeof UnknownType, strict?: boolean): unknown; static coerceType<T>(input: unknown, type: Class<T> | Function, strict?: boolean): T; static coerceType(input: unknown, type: Class<unknown> | Function, strict = true): unknown { // Do nothing if (input === null || input === undefined) { return input; } else if (!strict && type !== String && input === '') { return undefined; // treat empty string as undefined for non-strings in non-strict mode } else if (type && input instanceof type) { return input; } switch (type) { case Date: { let value: Date | undefined; if (typeof input === 'object' && 'toDate' in input && typeof input.toDate === 'function') { value = castTo(input.toDate()); } else { value = input instanceof Date ? input : typeof input === 'number' ? new Date(input) : (typeof input === 'string' && /^[-]?\d+$/.test(input)) ? new Date(parseInt(input, 10)) : new Date(input.toString()); } if (strict && value && Number.isNaN(value.getTime())) { throw new Error(`Invalid date value: ${input}`); } return value; } case Number: { const value = `${input}`.includes('.') ? parseFloat(`${input}`) : parseInt(`${input}`, 10); if (strict && Number.isNaN(value)) { throw new Error(`Invalid numeric value: ${input}`); } return value; } case BigInt: { if (typeof input === 'bigint') { return input; } try { return BigInt((typeof input === 'boolean' || typeof input === 'number') ? input : `${input}`.replace(/n$/i, '')); } catch { if (strict) { throw new Error(`Invalid numeric value: ${input}`); } return; } } case Boolean: { const match = `${input}`.match(/^((?<TRUE>true|yes|1|on)|false|no|off|0)$/i); if (strict && !match) { throw new Error(`Invalid boolean value: ${input}`); } return !!match?.groups?.TRUE; } case RegExp: { if (typeof input === 'string') { try { return this.toRegex(input); } catch { if (strict) { throw new Error(`Invalid regex: ${input}`); } else { return; } } } else if (strict) { throw new Error('Invalid regex type'); } else { return; } } case UnknownType: { return input; } case Object: { if (!strict || this.isPlainObject(input)) { return input; } else { throw new Error('Invalid object type'); } } case undefined: case String: return `${input}`; } if (!strict || this.isPlainObject(input)) { return input; } else { throw new Error(`Unknown type ${type.name}`); } } /** * Clone top level properties to a new object * @param value Object to clone */ static shallowClone<T>(value: T): T { return castTo(Array.isArray(value) ? value.slice(0) : (this.isSimpleValue(value) ? value : { ...castTo<object>(value) })); } /** * Deep assign from b to a * @param a The target * @param b The source * @param mode How the assignment should be handled */ static deepAssign<T, U>(a: T, b: U, mode: | 'replace' | 'loose' | 'strict' | 'coerce' = 'loose'): T & U { if (!a || this.isSimpleValue(a)) { throw new Error(`Cannot merge onto a simple value, ${a}`); } return castTo(this.#deepAssignRaw(a, b, mode)); } /** * Filter object by excluding specific keys * @param input A value to filter, primitives will be untouched * @param exclude Strings or patterns to exclude against * @returns */ static filterByKeys<T>(input: T, exclude: (string | RegExp)[]): T { if (Array.isArray(input)) { return castTo(input.map(value => this.filterByKeys(value, exclude))); } else if (input !== null && input !== undefined && typeof input === 'object') { const out: Partial<T> = {}; for (const key of TypedObject.keys(input)) { if (!exclude.some(toMatch => typeof key === 'string' && (typeof toMatch === 'string' ? toMatch === key : toMatch.test(key)))) { const value = input[key]; if (typeof value === 'object') { out[key] = this.filterByKeys(value, exclude); } else { out[key] = value; } } } return asFull(out); } else { return input; } } }