diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1eabcda..5713011 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ import { clone, deepEqual, Dict, filterKeys, isNullable, isPlainObject, pick, valueMap } from 'cosmokit' const kSchema = Symbol.for('schemastery') +const kValidationError = Symbol.for('ValidationError') declare global { namespace Schemastery { @@ -33,6 +34,7 @@ declare global { export interface Static { (options: Partial>): Schema new (options: Partial>): Schema + ValidationError: typeof ValidationError prototype: Schema resolve: Resolve from(source?: X): From @@ -60,6 +62,8 @@ declare global { interface Options { autofix?: boolean + path?: (string | number | symbol)[] + __schemastery_debug__?: boolean } export interface Meta { @@ -133,9 +137,35 @@ globalThis.__schemastery_index__ ??= 0 type Schema = Schemastery +class ValidationError extends TypeError { + [kValidationError] = true + constructor(public errorMessage: string, public trace: (string | number | symbol)[] = []) { + let message = ''; + for (let elem of trace || []) { + if (typeof elem === 'string') message += '.' + elem + else if (typeof elem === 'number') message += '[' + elem + ']' + else if (typeof elem === 'symbol') message += elem.description + } + if (message.startsWith('.')) message = message.slice(1) + super((message ? message + ': ' : '') + errorMessage) + } +} + +const errorBuilder = (options: Schemastery.Options = {}) => (e: any) => { + if (e instanceof Schema.ValidationError) return e; + if (e instanceof Object && 'message' in e) return new Schema.ValidationError(e.message, options.path) + return e +} + const Schema = function (options: Schema) { - const schema = function (data: any, options?: Schemastery.Options) { - return Schema.resolve(data, schema, options)[0] + const schema = function (data: any, options: Schemastery.Options = {}) { + if (options?.__schemastery_debug__) return Schema.resolve(data, schema, options)[0] + try { + return Schema.resolve(data, schema, options)[0] + } catch (e) { + if (!(e instanceof Schema.ValidationError)) throw e + throw new Schema.ValidationError(e.errorMessage, e.trace) + } } as Schema if (options.refs) { @@ -165,6 +195,8 @@ const Schema = function (options: Schema) { return schema } as Schemastery.Static +Schema.ValidationError = ValidationError + Schema.prototype = Object.create(Function.prototype) Schema.prototype[kSchema] = true @@ -341,14 +373,20 @@ for (const key of ['default', 'link', 'comment', 'description', 'max', 'min', 's const resolvers: Dict = {} Schema.extend = function extend(type, resolve) { - resolvers[type] = resolve + resolvers[type] = (data: any, schema: Schema, options: Schemastery.Options = {}, strict?: boolean) => { + try { + return resolve(data, schema, options, strict) + } catch (e) { + throw errorBuilder(options)(e) + } + } } Schema.resolve = function resolve(data, schema, options = {}, strict = false) { if (!schema) return [data] if (isNullable(data)) { - if (schema.meta.required) throw new TypeError(`missing required value`) + if (schema.meta.required) throw errorBuilder(options)(`missing required value`) let current = schema let fallback = schema.meta.default while (current?.type === 'intersect' && isNullable(fallback)) { @@ -360,7 +398,7 @@ Schema.resolve = function resolve(data, schema, options = {}, strict = false) { } const callback = resolvers[schema.type] - if (!callback) throw new TypeError(`unsupported type "${schema.type}"`) + if (!callback) throw errorBuilder(options)(`unsupported type "${schema.type}"`) try { return callback(data, schema, options, strict) @@ -460,7 +498,7 @@ function isMultipleOf(data: number, min: number, step: number) { } Schema.extend('number', (data, { meta }) => { - if (typeof data !== 'number') throw new TypeError(`expected number but got ${data}`) + if (typeof data !== 'number') throw new TypeError(`expected number but got ${typeof data} ${data}`) checkWithinRange(data, meta, 'number') const { step } = meta if (step && !isMultipleOf(data, meta.min ?? 0, step)) { @@ -506,9 +544,10 @@ Schema.extend('is', (data, { callback }) => { throw new TypeError(`expected ${callback!.name} but got ${data}`) }) -function property(data: any, key: keyof any, schema: Schema, options?: Schemastery.Options) { +function property(data: any, key: keyof any, schema: Schema, options: Schemastery.Options = {}) { try { - const [value, adapted] = Schema.resolve(data[key], schema, options) + const newOptions: Schemastery.Options = { ...options, path: [...(options.path || []), key] } + const [value, adapted] = Schema.resolve(data[key], schema, newOptions) if (adapted !== undefined) data[key] = adapted return value } catch (e) { @@ -603,19 +642,23 @@ Schema.extend('intersect', (data, { list, toString }, options, strict) => { Schema.extend('transform', (data, { inner, callback, preserve }, options) => { const [result, adapted = data] = Schema.resolve(data, inner!, options, true) - if (preserve) { - return [callback!(result)] - // } else if (isPlainObject(data)) { - // const temp: any = {} - // for (const key in result) { - // if (!(key in data)) continue - // temp[key] = data[key] - // delete data[key] - // } - // Object.assign(data, callback!(temp)) - // return [callback!(result)] - } else { - return [callback!(result), callback!(adapted)] + try { + if (preserve) { + return [callback!(result)] + // } else if (isPlainObject(data)) { + // const temp: any = {} + // for (const key in result) { + // if (!(key in data)) continue + // temp[key] = data[key] + // delete data[key] + // } + // Object.assign(data, callback!(temp)) + // return [callback!(result)] + } else { + return [callback!(result), callback!(adapted)] + } + } catch (e) { + throw errorBuilder(options)(e) } })