/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/ban-types */ type Constructor = new (...args: any[]) => T; export type Primitive = string | number | symbol | boolean | undefined | null; export let collectError = false; export function setCollect(flag: boolean) { collectError = flag; } function deepClone(obj: T): T { if (obj === null || typeof obj !== 'object') { return obj; } if (Array.isArray(obj)) { return obj.map((item) => deepClone(item)) as T; } return Object.keys(obj as object).reduce((acc, key) => { acc[key as keyof T] = deepClone((obj as any)[key]); return acc; }, {} as T); } export abstract class Validator { path?: string; errors: string[] = []; abstract validate(x: unknown): x is T; protected fail(message: string): false; protected fail(options: { expect: string; actual: unknown }): false; protected fail(options: { expect: string; actual: unknown } | string) { if (collectError) { if (typeof options === 'string') { this.errors.push(options); } else { let message = `${this.path} should be ${options.expect}`; if (this._optional && options.expect !== 'undefined') { message += ' or undefined, '; } else { message += ', '; } message += `but got ${echo(options.actual)}`; this.errors.push(message); } } return false; } hasFallback = false; _fallback?: T; fallback(v: T) { this.hasFallback = true; this._fallback = v; return this; } get fallbackValue(): T { return deepClone(this._fallback as T); } hasDefault = false; protected _default?: T; default(v: T) { this.hasDefault = true; this._default = v; return this; } get defaultValue() { if (typeof this._default === 'function') { return this._default; } return deepClone(this._default as T); } _optional = false; optional(): Validator { this._optional = true; return this as Validator; } } export function isNumber(x: unknown): x is number { return typeof x === 'number'; } export function echo(x: unknown) { const s = getType(x); switch (s) { case 'string': return `"${x}"`; case 'number': case 'boolean': case 'null': case 'undefined': return String(x); default: return `a(n) ${s}`; } } export function getType(x: unknown) { if (typeof x === 'function') return 'function'; const type = Object.prototype.toString.call(x).slice(8).slice(0, -1); return type.toLowerCase(); } export class $number extends Validator { private _min?: number; private _max?: number; private _isInt?: boolean; private _greater?: number; validate(x: unknown): x is number { if (this._optional && x === undefined) return true; if (!isNumber(x)) { return this.fail({ expect: 'number', actual: x }); } if (isNumber(this._min) && x < this._min) { return this.fail(`the value of ${this.path} should be greater than or equal to ${this._min}`); } if (isNumber(this._max) && x > this._max) { return this.fail(`the value of ${this.path} should be less than or equal to ${this._max}`); } if (isNumber(this._greater) && x <= this._greater) { return this.fail(`the value of ${this.path} should be greater than ${this._greater}`); } if (this._isInt && x !== Math.floor(x)) { return this.fail(`the value of ${this.path} should be integer but got ${x}`); } return true; } min(v: number) { this._min = v; return this; } max(v: number) { this._max = v; return this; } isInt(v: boolean) { this._isInt = v; return this; } greater(v: number) { this._greater = v; return this; } } export class $boolean extends Validator { validate(x: unknown): x is boolean { if (this._optional && x === undefined) return true; if (typeof x !== 'boolean') { return this.fail({ expect: 'boolean', actual: x }); } return true; } } export class $string extends Validator { private _minLength?: number; private _maxLength?: number; private _pattern?: string | RegExp; validate(x: unknown): x is string { if (this._optional && x === undefined) return true; if (typeof x !== 'string') { return this.fail({ expect: 'string', actual: x }); } if (isNumber(this._maxLength) && x.length > this._maxLength) { return this.fail(`the length of ${this.path} should be less than or equal to ${this._maxLength}`); } if (isNumber(this._minLength) && x.length < this._minLength) { return this.fail(`the length of ${this.path} should be greater than or equal to ${this._minLength}`); } if (typeof this._pattern === 'string') { const reg = new RegExp(this._pattern); if (!reg.test(x)) { return this.fail(`${this.path} should match pattern "${this._pattern}"`); } } else if (this._pattern) { if (!this._pattern.test(x)) { return this.fail(`${this.path} should match pattern "${this._pattern.toString()}"`); } } return true; } minLength(len: number) { this._minLength = len; return this; } maxLength(len: number) { this._maxLength = len; return this; } pattern(patten: string | RegExp) { this._pattern = patten; return this; } } export class $symbol extends Validator { validate(x: unknown): x is symbol { if (this._optional && x === undefined) return true; if (typeof x !== 'symbol') { return this.fail({ expect: 'symbol', actual: x }); } return true; } } export class $any extends Validator { // eslint-disable-next-line class-methods-use-this validate(x: unknown): x is T { return true; } } export class $undefined extends Validator { validate(x: unknown): x is undefined { if (x !== undefined) { return this.fail({ expect: 'undefined', actual: x }); } return true; } } export class $null extends Validator { validate(x: unknown): x is null { if (this._optional && x === undefined) return true; if (x !== null) { return this.fail({ expect: 'null', actual: x }); } return true; } } export class $function extends Validator<(...args: any[]) => any> { validate(x: unknown): x is (...args: any[]) => any { if (this._optional && x === undefined) return true; if (typeof x !== 'function') { return this.fail({ expect: 'function', actual: x }); } return true; } } export class $enum extends Validator { private readonly _items: (string | number | undefined)[]; constructor(..._items: (string | number | undefined)[]) { super(); this._items = _items; } validate(x: unknown): x is T { if (this._optional && x === undefined) return true; if (!this._items.includes(x as any)) { const enums = this._items.map((i) => JSON.stringify(i)).join(', '); return this.fail(`expect ${this.path} to be one of ${enums}, but got ${echo(x)}`); } return true; } } export class $array extends Validator { private _minItems?: number; private _maxItems?: number; private _uniqueItems?: boolean; private _length?: number; constructor(private readonly _item?: Validator) { super(); } validate(x: unknown): x is T[] { if (this._optional && x === undefined) return true; if (!Array.isArray(x)) { return this.fail({ expect: 'array', actual: x }); } if (isNumber(this._minItems) && x.length < this._minItems) { return this.fail(`the length of ${this.path} should be greater than or equal to ${this._minItems}`); } if (isNumber(this._maxItems) && x.length > this._maxItems) { return this.fail(`the length of ${this.path} should be less than or equal to ${this._maxItems}`); } if (isNumber(this._length) && x.length !== this._length) { return this.fail(`the length of ${this.path} should be equal to ${this._length}`); } const validator = this._item; if (!validator) return true; let result = true; x.forEach((item, index) => { validator.path = `${this.path}[${index}]`; validator.errors = this.errors; if (item === undefined && validator.hasDefault) { x[index] = validator.defaultValue; return; } if (!validator.validate(item)) { if (validator.hasFallback) { x[index] = validator.fallbackValue; } else { result = false; } } }); if (this._uniqueItems) { const map = new Map(); for (const i in x) { if (map.has(x[i])) { result = this.fail( `${this.path} should NOT have duplicate items (${this.path}[${map.get(x[i])}] and ${ this.path }[${i}] are identical)` ); } else { map.set(x[i], i); } } } return result; } minItems(len: number) { this._minItems = len; return this; } maxItems(len: number) { this._maxItems = len; return this; } uniqueItems() { this._uniqueItems = true; return this; } length(len: number) { this._length = len; return this; } } export class $record extends Validator> { constructor(private readonly _value: Validator) { super(); } validate(x: unknown): x is Record { if (this._optional && x === undefined) return true; const type = getType(x); if (type !== 'object') { return this.fail({ expect: 'object', actual: x }); } const values = x as Record; const validator = this._value; validator.errors = this.errors; let result = true; // eslint-disable-next-line guard-for-in for (const k in values) { const value = values[k]; if (/^[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*$/.test(k)) { validator.path = `${this.path}.${k}`; } else { validator.path = `${this.path}["${k}"]`; } if (value === undefined && validator._optional) { continue; } if (value === undefined && validator.hasDefault) { values[k] = validator.defaultValue; continue; } if (!validator.validate(value)) { if (validator.hasFallback) { values[k] = validator.fallbackValue; } else { result = false; } } } return result; } } export class $object extends Validator { constructor(private readonly _object: Record = {}) { super(); } validate(x: unknown): x is T { if (this._optional && x === undefined) return true; if (getType(x) !== 'object') { return this.fail({ expect: 'object', actual: x }); } const values = x as Record; let result = true; // eslint-disable-next-line guard-for-in for (const k in this._object) { const value = values[k]; const validator = this._object[k]; validator.errors = this.errors; if (/^[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*$/.test(k)) { validator.path = `${this.path}.${k}`; } else { validator.path = `${this.path}["${k}"]`; } if (validator._optional && value === undefined) { continue; } if (value === undefined && validator.hasDefault) { values[k] = validator.defaultValue; continue; } if (!validator.validate(value)) { if (validator.hasFallback) { values[k] = validator.fallbackValue; } else { result = false; } } } return result; } } export class $literal extends Validator { constructor(private readonly value: T) { super(); } validate(x: unknown): x is T { if (x === undefined && this._optional) return true; if (x !== this.value) { return this.fail({ expect: echo(this.value), actual: x }); } return true; } } export class $or extends Validator { private readonly _items: Validator[]; constructor(..._items: Validator[]) { super(); this._items = _items; } validate(x: unknown): x is T { if (x === undefined && this._optional) return true; const orErrors: string[] = []; let result = false; const itemLength = this._items.length; for (let i = 0; i < itemLength; i++) { const validator = this._items[i]; validator.errors = []; validator.path = this.path; if (!validator.validate(x)) { orErrors.push(validator.errors.join('; ')); } else if (!result) { result = true; break; } } if (!result) { const grouped = orErrors.join('\nor '); this.errors.push(grouped); } return result; } } export class $tuple extends Validator { private readonly _items: Validator[]; private readonly _minItems: number; constructor(..._items: Validator[]) { super(); this._items = _items; let optionalCount = 0; let i = _items.length - 1; for (; i >= 0; i--) { const validator = _items[i]; if (validator._optional || validator.hasDefault) { optionalCount += 1; } else { break; } } this._minItems = _items.length - optionalCount; } validate(x: unknown): x is T { if (x === undefined && this._optional) return true; if (!Array.isArray(x)) { return this.fail({ expect: 'array', actual: x }); } if (x.length < this._minItems) { return this.fail(`the length of ${this.path} should be greater than or equal to ${this._minItems}`); } let result = true; this._items.forEach((validator, index) => { validator.path = `${this.path}[${index}]`; validator.errors = this.errors; const item = x[index]; if (item === undefined && validator.hasDefault) { x[index] = validator.defaultValue; return; } if (!validator.validate(x[index])) { if (validator.hasFallback) { x[index] = validator._fallback; } else { result = false; } } }); return result; } } export class $arraybuffer extends Validator { validate(x: unknown): x is ArrayBuffer { if (x === undefined && this._optional) return true; if (x instanceof ArrayBuffer) { return true; } return this.fail({ expect: 'arraybuffer', actual: x }); } } export type TypedArray = | Uint8Array | Int8Array | Uint8ClampedArray | Uint16Array | Int16Array | Uint32Array | Int32Array | Float32Array | Float64Array; export class $typedarray extends Validator { private readonly _ctor: Constructor; constructor(ctor: Constructor) { super(); this._ctor = ctor; } validate(x: unknown): x is T { if (x === undefined && this._optional) return true; if (x instanceof this._ctor) { return true; } return this.fail({ expect: `typedarray of ${this._ctor.name}`, actual: x }); } }