Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lovely-socks-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cleverbrush/schema': minor
---

fix(schema): make ~standard.validate return Promise to support async validation per Standard Schema v1 spec
6 changes: 3 additions & 3 deletions libs/schema-json/src/standardJsonSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down
10 changes: 10 additions & 0 deletions libs/schema/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions libs/schema/src/builders/ExternSchemaBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 8 additions & 6 deletions libs/schema/src/builders/SchemaBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TResult, TRequired, TNullable>
): Promise<
StandardSchemaV1.Result<
ResolvedSchemaType<TResult, TRequired, TNullable>
>
> {
// 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<
Expand Down
130 changes: 84 additions & 46 deletions libs/schema/src/builders/standard-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,58 +80,61 @@ 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 });
});

// ---------------------------------------------------------------------------
// 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'
});
Expand All @@ -144,28 +147,63 @@ 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' });
});

// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions websites/schema/app/docs/sections/standard-schema.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }] }`)
}}
/>
Expand Down
13 changes: 7 additions & 6 deletions websites/schema/app/showcases/t3-env/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 never);
if (!result.valid) {
return (result.errors as any)[0]?.message ?? 'Invalid value';
}
return null;
}

/* ── Per-key row ─────────────────────────────────────────────────────────── */
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion websites/schema/app/showcases/tanstack-form/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }] }`)
}}
/>
Expand Down
Loading