From 72585d4aedc364089d8ba0160e8693adaa1c68a7 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Mon, 6 Apr 2026 02:10:47 +0200 Subject: [PATCH] Improve prop-validation diagnostics --- README.md | 7 +- docs-site/src/content/docs/api/pval.md | 25 +++- .../src/content/docs/guide/components.md | 7 +- .../src/content/docs/guide/typescript.md | 2 +- package.json | 2 +- src/app/propValidators.ts | 116 ++++++++++++++++-- tests/app/component-head.spec.ts | 6 +- tests/app/prop-validation.spec.ts | 6 +- 8 files changed, 141 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 6f8f9a2..0f69849 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,8 @@ pval.oneOf(['create', 'edit'] as const) pval.arrayOf(pval.isString) pval.shape({ title: pval.isString, count: pval.isNumber }) pval.refOf(pval.isString) -pval.fail('title', 'expected non-empty string') +pval.describe('value') +pval.fail('title', 'expected non-empty string, got string ("")') ``` ### Dynamic bindings and refs @@ -204,7 +205,7 @@ import { pval, type PropValidator } from 'regor' const isNonEmptyString: PropValidator = (value, name) => { if (typeof value !== 'string' || value.trim() === '') { - pval.fail(name, 'expected non-empty string') + pval.fail(name, `expected non-empty string, ${pval.describe(value)}`) } } @@ -219,7 +220,7 @@ Custom validators can also use the third `head` argument: const startsWithPrefix: PropValidator = (value, name, head) => { const ctx = head.requireContext(AppServices) if (typeof value !== 'string' || !value.startsWith(ctx.prefix)) { - pval.fail(name, 'expected prefixed value') + pval.fail(name, `expected prefixed value, ${pval.describe(value)}`) } } ``` diff --git a/docs-site/src/content/docs/api/pval.md b/docs-site/src/content/docs/api/pval.md index b8b5be1..5e9a643 100644 --- a/docs-site/src/content/docs/api/pval.md +++ b/docs-site/src/content/docs/api/pval.md @@ -136,7 +136,7 @@ head.validateProps({ Nested errors include the failing index: ```txt -Invalid prop "tags[1]": expected string. +Invalid prop "tags[1]": expected string, got number (2). ``` ### `pval.shape({ ... })` @@ -155,7 +155,7 @@ head.validateProps({ Nested errors include the failing path: ```txt -Invalid prop "meta.slug": expected string. +Invalid prop "meta.slug": expected string, got number (2). ``` ### `pval.refOf(validator)` @@ -170,6 +170,21 @@ head.validateProps({ }) ``` +### `pval.describe(value)` + +Returns the same `got ...` description used by Regor's built-in validators. + +This is useful in custom validators when you want your own error details to +match the built-in validator style, including explicit Regor ref handling. + +```ts +const isPositiveNumber: PropValidator = (value, name) => { + if (typeof value !== 'number' || value <= 0) { + pval.fail(name, `expected positive number, ${pval.describe(value)}`) + } +} +``` + ### `pval.fail(name, detail)` Throws the same structured validation failure used by Regor's built-in validators. @@ -181,7 +196,7 @@ format the final component-aware error consistently. ```ts const isNonEmptyString: PropValidator = (value, name) => { if (typeof value !== 'string' || value.trim() === '') { - pval.fail(name, 'expected non-empty string') + pval.fail(name, `expected non-empty string, ${pval.describe(value)}`) } } ``` @@ -201,7 +216,7 @@ import { pval, type PropValidator } from 'regor' const isNonEmptyString: PropValidator = (value, name) => { if (typeof value !== 'string' || value.trim() === '') { - pval.fail(name, 'expected non-empty string') + pval.fail(name, `expected non-empty string, ${pval.describe(value)}`) } } ``` @@ -212,7 +227,7 @@ Custom validators also receive `head` as the third argument: const startsWithPrefix: PropValidator = (value, name, head) => { const services = head.requireContext(AppServices) if (typeof value !== 'string' || !value.startsWith(services.prefix)) { - pval.fail(name, 'expected prefixed value') + pval.fail(name, `expected prefixed value, ${pval.describe(value)}`) } } ``` diff --git a/docs-site/src/content/docs/guide/components.md b/docs-site/src/content/docs/guide/components.md index df692b6..e0987a3 100644 --- a/docs-site/src/content/docs/guide/components.md +++ b/docs-site/src/content/docs/guide/components.md @@ -163,7 +163,8 @@ const EditorCard = defineComponent( 8. `pval.arrayOf(validator)` 9. `pval.shape({ ... })` 10. `pval.refOf(validator)` -11. `pval.fail(name, detail)` +11. `pval.describe(value)` +12. `pval.fail(name, detail)` Example: @@ -230,7 +231,7 @@ import { pval, type PropValidator } from 'regor' const isNonEmptyString: PropValidator = (value, name) => { if (typeof value !== 'string' || value.trim() === '') { - pval.fail(name, 'expected non-empty string') + pval.fail(name, `expected non-empty string, ${pval.describe(value)}`) } } @@ -245,7 +246,7 @@ Custom validators can also use the third `head` argument: const startsWithParentPrefix: PropValidator = (value, name, head) => { const ctx = head.requireContext(AppServices) if (typeof value !== 'string' || !value.startsWith(ctx.prefix)) { - pval.fail(name, 'expected prefixed value') + pval.fail(name, `expected prefixed value, ${pval.describe(value)}`) } } ``` diff --git a/docs-site/src/content/docs/guide/typescript.md b/docs-site/src/content/docs/guide/typescript.md index 4cb0f51..cf1fbf8 100644 --- a/docs-site/src/content/docs/guide/typescript.md +++ b/docs-site/src/content/docs/guide/typescript.md @@ -166,7 +166,7 @@ import { pval, type PropValidator } from 'regor' const isNonEmptyString: PropValidator = (value, name) => { if (typeof value !== 'string' || value.trim() === '') { - pval.fail(name, 'expected non-empty string') + pval.fail(name, `expected non-empty string, ${pval.describe(value)}`) } } ``` diff --git a/package.json b/package.json index aeebd30..1065d98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "regor", - "version": "1.4.8", + "version": "1.4.9", "description": "A modern UI framework for web and desktop applications, inspired by Vue's concepts and powered by simplicity and flexibility.", "author": "Ahmed Yasin Koculu", "license": "MIT", diff --git a/src/app/propValidators.ts b/src/app/propValidators.ts index 616b7eb..dd8a832 100644 --- a/src/app/propValidators.ts +++ b/src/app/propValidators.ts @@ -77,7 +77,7 @@ export type InferPropValidationSchema> = * ```ts * const isNonEmptyString: PropValidator = (value, name) => { * if (typeof value !== 'string' || value.trim() === '') { - * pval.fail(name, 'expected non-empty string') + * pval.fail(name, `expected non-empty string, ${pval.describe(value)}`) * } * } * ``` @@ -93,6 +93,9 @@ export const fail = (name: string, message: string): never => { const describeValue = (value: unknown): string => { if (value === null) return 'null' if (value === undefined) return 'undefined' + if (isRef(value)) { + return `ref<${describeValue(value())}>` + } if (typeof value === 'string') return 'string' if (typeof value === 'number') return 'number' if (typeof value === 'boolean') return 'boolean' @@ -109,6 +112,84 @@ const describeValue = (value: unknown): string => { return ctorName && ctorName !== 'Object' ? ctorName : 'object' } +const trimPreview = (value: string): string => { + return value.length > 60 ? `${value.slice(0, 57)}...` : value +} + +const formatPreview = (value: unknown, depth = 0): string => { + const maxPreviewDepth = 1 + const maxArrayItems = 5 + const maxObjectEntries = 5 + + if (depth > maxPreviewDepth) return 'unknown' + if (isRef(value)) { + const refValue = value() + return `ref(${formatPreview(refValue, depth + 1)})` + } + if (typeof value === 'string') return trimPreview(JSON.stringify(value)) + if ( + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' + ) { + return String(value) + } + if (typeof value === 'symbol') return String(value) + if (value === null) return 'null' + if (value === undefined) return 'undefined' + if (value instanceof Date) return value.toISOString() + if (value instanceof RegExp) return String(value) + if (isArray(value)) { + const items = value + .slice(0, maxArrayItems) + .map((x) => formatPreview(x, depth + 1)) + .join(', ') + return value.length > maxArrayItems ? `[${items}, ...]` : `[${items}]` + } + if (isObject(value)) { + const entries = Object.entries(value).slice(0, maxObjectEntries) + if (entries.length === 0) return '{}' + const text = entries + .map(([key, entry]) => { + const preview = formatPreview(entry, depth + 1) + return `${key}: ${preview}` + }) + .join(', ') + return Object.keys(value).length > maxObjectEntries + ? `{ ${text}, ... }` + : `{ ${text} }` + } + return 'unknown' +} + +/** + * Describes the received runtime value in the same style used by Regor's + * built-in prop validators. + * + * This is useful in custom validators when you want failure messages to stay + * consistent with `pval.isString`, `pval.isNumber`, `pval.refOf(...)`, and the + * other built-ins. The description includes both a received type label and a + * compact preview of the value. Regor refs are shown explicitly together with + * their current value. + * + * Example: + * ```ts + * const isPositiveNumber: PropValidator = (value, name) => { + * if (typeof value !== 'number' || value <= 0) { + * pval.fail(name, `expected positive number, ${pval.describe(value)}`) + * } + * } + * ``` + * + * @param value - Raw runtime value that failed validation. + * @returns A compact `got ...` description suitable for validator errors. + */ +export const describe = (value: unknown): string => { + const type = describeValue(value) + const preview = formatPreview(value) + return `got ${type} (${preview})` +} + const formatLiteral = (value: unknown): string => { if (typeof value === 'string') return `"${value}"` if (typeof value === 'number' || typeof value === 'boolean') { @@ -123,21 +204,24 @@ const formatLiteral = (value: unknown): string => { * Validates that a prop is a string. */ export const isString: PropValidator = (value, name) => { - if (typeof value !== 'string') fail(name, 'expected string') + if (typeof value !== 'string') + fail(name, `expected string, ${describe(value)}`) } /** * Validates that a prop is a number. */ export const isNumber: PropValidator = (value, name) => { - if (typeof value !== 'number') fail(name, 'expected number') + if (typeof value !== 'number') + fail(name, `expected number, ${describe(value)}`) } /** * Validates that a prop is a boolean. */ export const isBoolean: PropValidator = (value, name) => { - if (typeof value !== 'boolean') fail(name, 'expected boolean') + if (typeof value !== 'boolean') + fail(name, `expected boolean, ${describe(value)}`) } /** @@ -151,7 +235,10 @@ export const isClass = ( ): PropValidator => { return (value, name) => { if (!(value instanceof ctor)) { - fail(name, `expected instance of ${ctor.name || 'provided class'}`) + fail( + name, + `expected instance of ${ctor.name || 'provided class'}, ${describe(value)}`, + ) } } } @@ -190,7 +277,7 @@ export const oneOf = ( if (values.includes(value)) return fail( name, - `expected one of ${values.map((x) => formatLiteral(x)).join(', ')}`, + `expected one of ${values.map((x) => formatLiteral(x)).join(', ')}, ${describe(value)}`, ) } } @@ -203,7 +290,7 @@ export const arrayOf = ( validator: PropValidator, ): PropValidator => { return (value, name, head) => { - if (!isArray(value)) fail(name, 'expected array') + if (!isArray(value)) fail(name, `expected array, ${describe(value)}`) const items = value as unknown[] for (let i = 0; i < items.length; ++i) { validator(items[i], `${name}[${i}]`, head) @@ -219,7 +306,7 @@ export const shape = ( schema: TSchema, ): PropValidator> => { return (value, name, head) => { - if (!isObject(value)) fail(name, 'expected object') + if (!isObject(value)) fail(name, `expected object, ${describe(value)}`) const record = value as Record for (const key in schema) { const validator: PropValidator = schema[key] @@ -238,9 +325,11 @@ export const refOf = ( validator: PropValidator, ): PropValidator => { return (value, name, head) => { - if (!isRef(value)) fail(name, 'expected ref') - const refValue = value as AnyRef - validator(refValue(), `${name}.value`, head) + if (isRef(value)) { + validator(value(), `${name}.value`, head) + return + } + fail(name, `expected ref, ${describe(value)}`) } } @@ -248,8 +337,8 @@ export const refOf = ( * Built-in prop-validator namespace used with `head.validateProps(...)`. * * This namespace includes both ready-made validators and composition helpers. - * Custom validators can also use `pval.fail(...)` to produce the same - * structured failure shape as Regor's built-in validators. + * Custom validators can also use `pval.fail(...)` and `pval.describe(...)` + * to produce messages in the same style as Regor's built-in validators. * * Example: * ```ts @@ -264,6 +353,7 @@ export const refOf = ( */ export const pval = { fail, + describe, isString, isNumber, isBoolean, diff --git a/tests/app/component-head.spec.ts b/tests/app/component-head.spec.ts index 0d31bfa..7be60ff 100644 --- a/tests/app/component-head.spec.ts +++ b/tests/app/component-head.spec.ts @@ -163,7 +163,9 @@ test('component head validateProps throws with nested prop path details', () => tags: pval.arrayOf(pval.isString), }), }), - ).toThrow('Invalid prop "meta.tags[1]" on
: expected string.') + ).toThrow( + 'Invalid prop "meta.tags[1]" on
: expected string, got number (2).', + ) }) test('component head validator utilities support class and ref validation', () => { @@ -312,7 +314,7 @@ test('component head validateProps warns instead of throwing in warn mode', () = ).not.toThrow() expect(warnSpy).toHaveBeenCalledTimes(1) expect(String(warnSpy.mock.calls[0][0])).toContain( - 'Invalid prop "count" on
: expected number.', + 'Invalid prop "count" on
: expected number, got string ("bad").', ) } finally { warnSpy.mockRestore() diff --git a/tests/app/prop-validation.spec.ts b/tests/app/prop-validation.spec.ts index bc8d4a0..1778333 100644 --- a/tests/app/prop-validation.spec.ts +++ b/tests/app/prop-validation.spec.ts @@ -138,7 +138,9 @@ test('component props validation throws before invalid component mount completes template: html``, }, ), - ).toThrow('Invalid prop "count" on : expected number.') + ).toThrow( + 'Invalid prop "count" on : expected number, got ref (ref("oops")).', + ) expect(root.querySelector('.summary')).toBeNull() }) @@ -187,7 +189,7 @@ test('component props validation warns and continues mount when config mode is w ).not.toThrow() expect(warnSpy).toHaveBeenCalledTimes(1) expect(String(warnSpy.mock.calls[0][0])).toContain( - 'Invalid prop "count" on : expected number.', + 'Invalid prop "count" on : expected number, got string ("oops").', ) expect(root.querySelector('.summary')?.textContent).toBe('oops') } finally {