From 9e9bb4cbc60ea7c282ac20c536c6be51de66a6ea Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Sat, 2 May 2026 13:41:10 +0000 Subject: [PATCH 1/3] feat: add async to standard schema .validate() --- .changeset/lovely-socks-argue.md | 5 + .../src/standardJsonSchema.test.ts | 6 +- libs/schema/README.md | 10 ++ .../src/builders/ExternSchemaBuilder.test.ts | 8 +- libs/schema/src/builders/SchemaBuilder.ts | 14 +- .../src/builders/standard-schema.test.ts | 130 +++++++++++------- 6 files changed, 114 insertions(+), 59 deletions(-) create mode 100644 .changeset/lovely-socks-argue.md diff --git a/.changeset/lovely-socks-argue.md b/.changeset/lovely-socks-argue.md new file mode 100644 index 00000000..7e2dd1e1 --- /dev/null +++ b/.changeset/lovely-socks-argue.md @@ -0,0 +1,5 @@ +--- +'@cleverbrush/schema': patch +--- + +fix(schema): make ~standard.validate return Promise to support async validation per Standard Schema v1 spec diff --git a/libs/schema-json/src/standardJsonSchema.test.ts b/libs/schema-json/src/standardJsonSchema.test.ts index fc19a526..7c9f13f3 100644 --- a/libs/schema-json/src/standardJsonSchema.test.ts +++ b/libs/schema-json/src/standardJsonSchema.test.ts @@ -113,12 +113,12 @@ describe('withStandardJsonSchema', () => { // Preserves original schema behaviour // ----------------------------------------------------------------------- - test('wrapped schema still validates via ~standard.validate', () => { + test('wrapped schema still validates via ~standard.validate', async () => { const wrapped = withStandardJsonSchema(string()); - const pass = wrapped['~standard'].validate('hello'); + const pass = await wrapped['~standard'].validate('hello'); expect(pass).toEqual({ value: 'hello' }); - const fail = wrapped['~standard'].validate(123); + const fail = await wrapped['~standard'].validate(123); expect(fail).toHaveProperty('issues'); }); diff --git a/libs/schema/README.md b/libs/schema/README.md index c66af55f..64e5d1c5 100644 --- a/libs/schema/README.md +++ b/libs/schema/README.md @@ -1766,6 +1766,16 @@ const UserSchema = object({ const standardSchema = UserSchema['~standard']; ``` +The `['~standard'].validate()` method returns a `Promise`, supporting both sync +and async preprocessors, validators, and error message providers: + +```ts +const result = await UserSchema['~standard'].validate(input); +if ('issues' in result) { + console.error(result.issues[0].message); +} +``` + Confirmed integrations: **tRPC**, **TanStack Form**, **React Hook Form**, **T3 Env**, **Hono**, **Elysia**, **next-safe-action**, and 50+ others listed on [standardschema.dev](https://standardschema.dev/). ## Code Quality diff --git a/libs/schema/src/builders/ExternSchemaBuilder.test.ts b/libs/schema/src/builders/ExternSchemaBuilder.test.ts index 59350f54..83f6cfd3 100644 --- a/libs/schema/src/builders/ExternSchemaBuilder.test.ts +++ b/libs/schema/src/builders/ExternSchemaBuilder.test.ts @@ -469,15 +469,15 @@ test('safeParse() works like validate()', () => { // ~standard on extern builder itself // --------------------------------------------------------------------------- -test('extern builder ~standard.validate works', () => { +test('extern builder ~standard.validate works', async () => { const schema = extern(mockStringSchema); - const result = schema['~standard'].validate('hello'); + const result = await schema['~standard'].validate('hello'); expect(result).toEqual({ value: 'hello' }); }); -test('extern builder ~standard.validate returns issues on failure', () => { +test('extern builder ~standard.validate returns issues on failure', async () => { const schema = extern(mockStringSchema); - const result = schema['~standard'].validate(123); + const result = await schema['~standard'].validate(123); expect(result).toHaveProperty('issues'); const issues = (result as StandardSchemaV1.FailureResult).issues; expect(issues.length).toBeGreaterThan(0); diff --git a/libs/schema/src/builders/SchemaBuilder.ts b/libs/schema/src/builders/SchemaBuilder.ts index 1dc13e34..6b119bca 100644 --- a/libs/schema/src/builders/SchemaBuilder.ts +++ b/libs/schema/src/builders/SchemaBuilder.ts @@ -858,16 +858,18 @@ export abstract class SchemaBuilder< this.#standardProps = { version: 1 as const, vendor: '@cleverbrush/schema', - validate( + async validate( value: unknown - ): StandardSchemaV1.Result< - ResolvedSchemaType + ): Promise< + StandardSchemaV1.Result< + ResolvedSchemaType + > > { // Standard Schema validate accepts `unknown`, while the - // schema's own validate() has a typed parameter. The cast - // is safe because validate() performs full runtime + // schema's own validateAsync() has a typed parameter. The cast + // is safe because validateAsync() performs full runtime // validation regardless of the compile-time input type. - const result = self.validate(value as any); + const result = await self.validateAsync(value as any); if (result.valid) { return { value: result.object as ResolvedSchemaType< diff --git a/libs/schema/src/builders/standard-schema.test.ts b/libs/schema/src/builders/standard-schema.test.ts index c2e94091..c891924a 100644 --- a/libs/schema/src/builders/standard-schema.test.ts +++ b/libs/schema/src/builders/standard-schema.test.ts @@ -80,26 +80,29 @@ test('~standard is cached (same reference)', () => { // Successful validation // --------------------------------------------------------------------------- -test('validate returns success result for valid string', () => { - const result = string()['~standard'].validate('hello'); +test('validate returns success result for valid string', async () => { + const result = await string()['~standard'].validate('hello'); expect(result).toEqual({ value: 'hello' }); expect(result).not.toHaveProperty('issues'); }); -test('validate returns success result for valid number', () => { - const result = number()['~standard'].validate(42); +test('validate returns success result for valid number', async () => { + const result = await number()['~standard'].validate(42); expect(result).toEqual({ value: 42 }); }); -test('validate returns success result for valid object', () => { +test('validate returns success result for valid object', async () => { const schema = object({ name: string(), age: number() }); - const result = schema['~standard'].validate({ name: 'Alice', age: 30 }); + const result = await schema['~standard'].validate({ + name: 'Alice', + age: 30 + }); expect(result).toEqual({ value: { name: 'Alice', age: 30 } }); }); -test('validate returns success for optional schema with undefined', () => { +test('validate returns success for optional schema with undefined', async () => { const schema = string().optional(); - const result = schema['~standard'].validate(undefined); + const result = await schema['~standard'].validate(undefined); expect(result).toEqual({ value: undefined }); }); @@ -107,31 +110,31 @@ test('validate returns success for optional schema with undefined', () => { // Failed validation // --------------------------------------------------------------------------- -test('validate returns failure result for invalid string', () => { - const result = string()['~standard'].validate(123); +test('validate returns failure result for invalid string', async () => { + const result = await string()['~standard'].validate(123); expect(result).toHaveProperty('issues'); const issues = (result as StandardSchemaV1.FailureResult).issues; expect(issues.length).toBeGreaterThan(0); expect(typeof issues[0].message).toBe('string'); }); -test('validate returns failure result for required schema with null', () => { - const result = string()['~standard'].validate(null); +test('validate returns failure result for required schema with null', async () => { + const result = await string()['~standard'].validate(null); expect(result).toHaveProperty('issues'); const issues = (result as StandardSchemaV1.FailureResult).issues; expect(issues.length).toBeGreaterThan(0); }); -test('validate returns failure result for required schema with undefined', () => { - const result = string()['~standard'].validate(undefined); +test('validate returns failure result for required schema with undefined', async () => { + const result = await string()['~standard'].validate(undefined); expect(result).toHaveProperty('issues'); const issues = (result as StandardSchemaV1.FailureResult).issues; expect(issues.length).toBeGreaterThan(0); }); -test('validate returns failure with multiple issues for invalid object', () => { +test('validate returns failure with multiple issues for invalid object', async () => { const schema = object({ name: string(), age: number() }); - const result = schema['~standard'].validate({ + const result = await schema['~standard'].validate({ name: 123, age: 'not a number' }); @@ -144,12 +147,12 @@ test('validate returns failure with multiple issues for invalid object', () => { // Preprocessors work through ~standard.validate // --------------------------------------------------------------------------- -test('preprocessors are applied through ~standard.validate', () => { +test('preprocessors are applied through ~standard.validate', async () => { const schema = string().addPreprocessor((v: any) => { if (typeof v === 'number') return String(v); return v; }); - const result = schema['~standard'].validate(42); + const result = await schema['~standard'].validate(42); expect(result).toEqual({ value: '42' }); }); @@ -157,15 +160,50 @@ test('preprocessors are applied through ~standard.validate', () => { // Validators with constraints // --------------------------------------------------------------------------- -test('string minLength constraint is enforced via ~standard.validate', () => { +test('string minLength constraint is enforced via ~standard.validate', async () => { const schema = string().minLength(3); - const fail = schema['~standard'].validate('ab'); + const fail = await schema['~standard'].validate('ab'); expect(fail).toHaveProperty('issues'); - const pass = schema['~standard'].validate('abc'); + const pass = await schema['~standard'].validate('abc'); expect(pass).toEqual({ value: 'abc' }); }); +// --------------------------------------------------------------------------- +// Async validation through ~standard.validate +// --------------------------------------------------------------------------- + +test('async preprocessor works through ~standard.validate', async () => { + const schema = string().addPreprocessor(async (v: any) => { + if (typeof v === 'number') return String(v); + return v; + }); + const result = await schema['~standard'].validate(42); + expect(result).toEqual({ value: '42' }); +}); + +test('async validator works through ~standard.validate', async () => { + const schema = string().addValidator(async (v: any) => { + if (v === 'hello') { + return { valid: true, errors: [] }; + } + return { valid: false, errors: [{ message: 'not hello' }] }; + }); + const pass = await schema['~standard'].validate('hello'); + expect(pass).toEqual({ value: 'hello' }); + + const fail = await schema['~standard'].validate('world'); + expect(fail).toHaveProperty('issues'); + const issues = (fail as StandardSchemaV1.FailureResult).issues; + expect(issues[0].message).toMatch(/not hello/); +}); + +test('validate returns Promise from ~standard bridge', () => { + const schema = string(); + const result = schema['~standard'].validate('hello'); + expect(result).toBeInstanceOf(Promise); +}); + // --------------------------------------------------------------------------- // Type conformance — assignable to StandardSchemaV1 // --------------------------------------------------------------------------- @@ -219,64 +257,64 @@ test('StandardSchemaV1.InferOutput works with optional schema', () => { // Error shape — issue messages and mutual exclusivity of value / issues // --------------------------------------------------------------------------- -test('failure result has no value property', () => { - const result = string()['~standard'].validate(123); +test('failure result has no value property', async () => { + const result = await string()['~standard'].validate(123); expect(result).not.toHaveProperty('value'); expect(result).toHaveProperty('issues'); }); -test('success result has no issues property', () => { - const result = string()['~standard'].validate('hello'); +test('success result has no issues property', async () => { + const result = await string()['~standard'].validate('hello'); expect(result).toHaveProperty('value'); expect(result).not.toHaveProperty('issues'); }); -test('wrong type produces a descriptive message', () => { - const result = string()['~standard'].validate( +test('wrong type produces a descriptive message', async () => { + const result = (await string()['~standard'].validate( 123 - ) as StandardSchemaV1.FailureResult; + )) as StandardSchemaV1.FailureResult; expect(result.issues[0].message).toMatch(/string/i); }); -test('required field missing produces a descriptive message', () => { - const result = string()['~standard'].validate( +test('required field missing produces a descriptive message', async () => { + const result = (await string()['~standard'].validate( undefined - ) as StandardSchemaV1.FailureResult; + )) as StandardSchemaV1.FailureResult; expect(result.issues[0].message).toMatch(/required/i); }); -test('null value produces a descriptive message', () => { - const result = string()['~standard'].validate( +test('null value produces a descriptive message', async () => { + const result = (await string()['~standard'].validate( null - ) as StandardSchemaV1.FailureResult; + )) as StandardSchemaV1.FailureResult; expect(result.issues[0].message).toBeTruthy(); }); -test('minLength violation produces a descriptive message', () => { - const result = string() +test('minLength violation produces a descriptive message', async () => { + const result = (await string() .minLength(5) - ['~standard'].validate('ab') as StandardSchemaV1.FailureResult; + ['~standard'].validate('ab')) as StandardSchemaV1.FailureResult; expect(result.issues[0].message).toMatch(/5/); }); -test('number min violation produces a descriptive message', () => { - const result = number() +test('number min violation produces a descriptive message', async () => { + const result = (await number() .min(10) - ['~standard'].validate(3) as StandardSchemaV1.FailureResult; + ['~standard'].validate(3)) as StandardSchemaV1.FailureResult; expect(result.issues[0].message).toMatch(/10/); }); -test('custom error message string is propagated through issues', () => { - const result = string() +test('custom error message string is propagated through issues', async () => { + const result = (await string() .minLength(3, 'name too short') - ['~standard'].validate('a') as StandardSchemaV1.FailureResult; + ['~standard'].validate('a')) as StandardSchemaV1.FailureResult; expect(result.issues[0].message).toBe('name too short'); }); -test('each issue has a non-empty string message', () => { - const result = string()['~standard'].validate( +test('each issue has a non-empty string message', async () => { + const result = (await string()['~standard'].validate( false - ) as StandardSchemaV1.FailureResult; + )) as StandardSchemaV1.FailureResult; for (const issue of result.issues) { expect(typeof issue.message).toBe('string'); expect(issue.message.length).toBeGreaterThan(0); From 29c52b02745778dcdd48105125f6ac749636f15e Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Tue, 5 May 2026 08:32:35 +0000 Subject: [PATCH 2/3] chore: improved documentation --- .changeset/lovely-socks-argue.md | 2 +- .../schema/app/docs/sections/standard-schema.tsx | 4 ++-- websites/schema/app/showcases/t3-env/page.tsx | 13 +++++++------ .../schema/app/showcases/tanstack-form/page.tsx | 2 +- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.changeset/lovely-socks-argue.md b/.changeset/lovely-socks-argue.md index 7e2dd1e1..12e650fe 100644 --- a/.changeset/lovely-socks-argue.md +++ b/.changeset/lovely-socks-argue.md @@ -1,5 +1,5 @@ --- -'@cleverbrush/schema': patch +'@cleverbrush/schema': minor --- fix(schema): make ~standard.validate return Promise to support async validation per Standard Schema v1 spec diff --git a/websites/schema/app/docs/sections/standard-schema.tsx b/websites/schema/app/docs/sections/standard-schema.tsx index 08d97339..36f3597b 100644 --- a/websites/schema/app/docs/sections/standard-schema.tsx +++ b/websites/schema/app/docs/sections/standard-schema.tsx @@ -95,10 +95,10 @@ const std = UserSchema['~standard']; console.log(std.version); // 1 console.log(std.vendor); // '@cleverbrush/schema' -const ok = std.validate({ name: 'Alice', email: 'alice@example.com' }); +const ok = await std.validate({ name: 'Alice', email: 'alice@example.com' }); // { value: { name: 'Alice', email: 'alice@example.com', age: undefined } } -const fail = std.validate({ name: 'A', email: 'not-an-email' }); +const fail = await std.validate({ name: 'A', email: 'not-an-email' }); // { issues: [{ message: 'minLength' }, { message: 'email' }] }`) }} /> diff --git a/websites/schema/app/showcases/t3-env/page.tsx b/websites/schema/app/showcases/t3-env/page.tsx index 3643fc37..941d92e1 100644 --- a/websites/schema/app/showcases/t3-env/page.tsx +++ b/websites/schema/app/showcases/t3-env/page.tsx @@ -62,10 +62,11 @@ function validateRaw(key: EnvKey, raw: string): string | null { : key === 'NEXT_PUBLIC_ENABLE_ANALYTICS' ? raw === 'true' : raw; - const result = schema['~standard'].validate(coerced); - if (result instanceof Promise) return null; // sync only in this demo - if (!result.issues) return null; - return result.issues[0]?.message ?? 'Invalid value'; + const result = schema.validate(coerced as any); + if (!result.valid) { + return result.errors[0]?.message ?? 'Invalid value'; + } + return null; } /* ── Per-key row ─────────────────────────────────────────────────────────── */ @@ -417,10 +418,10 @@ export function AnalyticsBanner() { const schema = string().required('required').minLength(8, 'too short'); // T3 Env calls this internally: -const result = schema['~standard'].validate('hi'); +const result = await schema['~standard'].validate('hi'); // result => { issues: [{ message: 'too short' }] } -const ok = schema['~standard'].validate('long enough value'); +const ok = await schema['~standard'].validate('long enough value'); // ok => { value: 'long enough value' } console.log(schema['~standard'].version); // => 1 diff --git a/websites/schema/app/showcases/tanstack-form/page.tsx b/websites/schema/app/showcases/tanstack-form/page.tsx index 70501dcd..205e5677 100644 --- a/websites/schema/app/showcases/tanstack-form/page.tsx +++ b/websites/schema/app/showcases/tanstack-form/page.tsx @@ -489,7 +489,7 @@ const bioSchema = string() // Standard Schema interface: // nameSchema['~standard'].version // => 1 // nameSchema['~standard'].vendor // => '@cleverbrush/schema' -// nameSchema['~standard'].validate('Hi') +// await nameSchema['~standard'].validate('Hi') // => { issues: [{ message: 'Name must be at least 2 characters' }] }`) }} /> From 0fe45b3ae2028779f3f55c74163447eb47d2a7ad Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Tue, 5 May 2026 13:43:09 +0000 Subject: [PATCH 3/3] fix: update validation logic to use 'never' type for coerced values --- websites/schema/app/showcases/t3-env/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/websites/schema/app/showcases/t3-env/page.tsx b/websites/schema/app/showcases/t3-env/page.tsx index 941d92e1..7e37c1af 100644 --- a/websites/schema/app/showcases/t3-env/page.tsx +++ b/websites/schema/app/showcases/t3-env/page.tsx @@ -62,9 +62,9 @@ function validateRaw(key: EnvKey, raw: string): string | null { : key === 'NEXT_PUBLIC_ENABLE_ANALYTICS' ? raw === 'true' : raw; - const result = schema.validate(coerced as any); + const result = schema.validate(coerced as never); if (!result.valid) { - return result.errors[0]?.message ?? 'Invalid value'; + return (result.errors as any)[0]?.message ?? 'Invalid value'; } return null; }