Skip to content

Commit fb236e1

Browse files
committed
feat(utils): provide validateAsync alternative to synchronous validate
1 parent ee31b9e commit fb236e1

File tree

3 files changed

+97
-3
lines changed

3 files changed

+97
-3
lines changed

packages/models/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export { exists } from './lib/implementation/utils.js';
7474
export {
7575
SchemaValidationError,
7676
validate,
77+
validateAsync,
7778
} from './lib/implementation/validate.js';
7879
export {
7980
issueSchema,

packages/models/src/lib/implementation/validate.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ type SchemaValidationContext = {
66
filePath?: string;
77
};
88

9+
/**
10+
* Autocompletes valid Zod Schema input for convience, but will accept any other data as well
11+
*/
12+
type ZodInputLooseAutocomplete<T extends ZodType> =
13+
| z.input<T>
14+
| {}
15+
| null
16+
| undefined;
17+
918
export class SchemaValidationError extends Error {
1019
constructor(
1120
error: ZodError,
@@ -29,7 +38,7 @@ export class SchemaValidationError extends Error {
2938

3039
export function validate<T extends ZodType>(
3140
schema: T,
32-
data: z.input<T> | {} | null | undefined, // loose autocomplete
41+
data: ZodInputLooseAutocomplete<T>,
3342
context: SchemaValidationContext = {},
3443
): z.output<T> {
3544
const result = schema.safeParse(data);
@@ -38,3 +47,15 @@ export function validate<T extends ZodType>(
3847
}
3948
throw new SchemaValidationError(result.error, schema, context);
4049
}
50+
51+
export async function validateAsync<T extends ZodType>(
52+
schema: T,
53+
data: ZodInputLooseAutocomplete<T>,
54+
context: SchemaValidationContext = {},
55+
): Promise<z.output<T>> {
56+
const result = await schema.safeParseAsync(data);
57+
if (result.success) {
58+
return result.data;
59+
}
60+
throw new SchemaValidationError(result.error, schema, context);
61+
}

packages/models/src/lib/implementation/validate.unit.test.ts

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import ansis from 'ansis';
2+
import { vol } from 'memfs';
3+
import { readFile, stat } from 'node:fs/promises';
24
import path from 'node:path';
3-
import z, { ZodError } from 'zod';
4-
import { SchemaValidationError, validate } from './validate.js';
5+
import { ZodError, z } from 'zod';
6+
import { SchemaValidationError, validate, validateAsync } from './validate.js';
57

68
describe('validate', () => {
79
it('should return parsed data if valid', () => {
@@ -37,6 +39,76 @@ describe('validate', () => {
3739
✖ Invalid ISO date
3840
→ at dateOfBirth`);
3941
});
42+
43+
it('should throw if async schema provided (handled by validateAsync)', () => {
44+
const projectNameSchema = z
45+
.string()
46+
.optional()
47+
.transform(
48+
async name =>
49+
name || JSON.parse(await readFile('package.json', 'utf8')).name,
50+
)
51+
.meta({ title: 'ProjectName' });
52+
53+
expect(() => validate(projectNameSchema, undefined)).toThrow(
54+
'Encountered Promise during synchronous parse. Use .parseAsync() instead.',
55+
);
56+
});
57+
});
58+
59+
describe('validateAsync', () => {
60+
it('should parse schema with async transform', async () => {
61+
vol.fromJSON({ 'package.json': '{ "name": "core" }' }, '/test');
62+
const projectNameSchema = z
63+
.string()
64+
.optional()
65+
.transform(
66+
async name =>
67+
name || JSON.parse(await readFile('package.json', 'utf8')).name,
68+
)
69+
.meta({ title: 'ProjectName' });
70+
71+
await expect(validateAsync(projectNameSchema, undefined)).resolves.toBe(
72+
'core',
73+
);
74+
});
75+
76+
it('should parse schema with async refinement', async () => {
77+
vol.fromJSON({ 'package.json': '{}' }, '/test');
78+
const filePathSchema = z
79+
.string()
80+
.refine(
81+
file =>
82+
stat(file)
83+
.then(stats => stats.isFile())
84+
.catch(() => false),
85+
{ error: 'File does not exist' },
86+
)
87+
.transform(file => path.resolve(process.cwd(), file))
88+
.meta({ title: 'FilePath' });
89+
90+
await expect(validateAsync(filePathSchema, 'package.json')).resolves.toBe(
91+
path.join(process.cwd(), 'package.json'),
92+
);
93+
});
94+
95+
it('should reject with formatted error if async schema is invalid', async () => {
96+
vol.fromJSON({}, '/test');
97+
const filePathSchema = z
98+
.string()
99+
.refine(
100+
file =>
101+
stat(file)
102+
.then(stats => stats.isFile())
103+
.catch(() => false),
104+
{ error: 'File does not exist' },
105+
)
106+
.meta({ title: 'FilePath' });
107+
108+
await expect(validateAsync(filePathSchema, 'package.json')).rejects.toThrow(
109+
`Invalid ${ansis.bold('FilePath')}\n✖ File does not exist`,
110+
);
111+
});
40112
});
41113

42114
describe('SchemaValidationError', () => {

0 commit comments

Comments
 (0)