Skip to content

Commit

Permalink
feat(schema): support zod schema for runtime validation (#39)
Browse files Browse the repository at this point in the history
  • Loading branch information
uladkasach authored May 11, 2024
1 parent 646be9e commit b6c4000
Show file tree
Hide file tree
Showing 9 changed files with 151 additions and 19 deletions.
18 changes: 17 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@
"ts-node": "10.9.1",
"typescript": "4.9.4",
"uuid": "^3.4.0",
"yup": "^0.29.3"
"yup": "^0.29.3",
"zod": "^3.23.8"
},
"config": {
"commitizen": {
Expand Down
41 changes: 41 additions & 0 deletions src/instantiation/DomainObject.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import Joi from 'joi';
import { v4 as uuid } from 'uuid';
import * as yup from 'yup';
import { z } from 'zod';

import { DomainObject } from './DomainObject';
import { HelpfulJoiValidationError } from './validate/HelpfulJoiValidationError';
import { HelpfulYupValidationError } from './validate/HelpfulYupValidationError';
import { HelpfulZodValidationError } from './validate/HelpfulZodValidationError';

describe('DomainObject', () => {
describe('domain modeling use cases', () => {
Expand Down Expand Up @@ -155,6 +157,45 @@ describe('DomainObject', () => {
});
});

describe('Zod schema', () => {
interface RocketShip {
serialNumber: string;
fuelQuantity: number;
passengers: number;
}
const schema = z.object({
serialNumber: z.string(),
fuelQuantity: z.number(),
passengers: z.number().max(42),
});
class RocketShip extends DomainObject<RocketShip> implements RocketShip {
public static schema = schema;
}
it('should not throw error if when valid', () => {
const ship = new RocketShip({
serialNumber: uuid(),
fuelQuantity: 9001,
passengers: 21,
});
expect(ship).toBeInstanceOf(RocketShip); // sanity check
});
it('should throw a helpful error when does not pass schema', () => {
try {
// eslint-disable-next-line no-new
new RocketShip({
serialNumber: '__SOME_UUID__',
fuelQuantity: 9001,
passengers: 50,
});
throw new Error('should not reach here');
} catch (error) {
if (!(error instanceof Error)) throw error;
expect(error).toBeInstanceOf(HelpfulZodValidationError);
expect(error.message).toMatchSnapshot();
}
});
});

describe('hydration', () => {
it('should hydrate nested domain objects', () => {
// define the plant pot
Expand Down
4 changes: 2 additions & 2 deletions src/instantiation/DomainObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ export class DomainObject<T extends Record<string, any>> {
*
* When set, will be used to validate the properties passed into the constructor at runtime (i.e., during instantiation)
*
* Supports [`Joi`](https://github.com/sideway/joi) and [`Yup`](https://github.com/jquense/yup)
* Supports [`Zod`](https://github.com/colinhacks/zod), [`Joi`](https://github.com/sideway/joi), and [`Yup`](https://github.com/jquense/yup)
*/
public static schema?: SchemaOptions;
public static schema?: SchemaOptions<any>; // todo: work around typescript's "static members cant reference class type parameters"

/**
* DomainObject.nested
Expand Down
24 changes: 24 additions & 0 deletions src/instantiation/__snapshots__/DomainObject.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,27 @@ Props Provided:
"passengers": 50
}"
`;

exports[`DomainObject validation Zod schema should throw a helpful error when does not pass schema 1`] = `
"Errors were found while validating properties for domain object RocketShip.:
[
{
"code": "too_big",
"maximum": 42,
"type": "number",
"inclusive": true,
"exact": false,
"message": "Number must be less than or equal to 42",
"path": [
"passengers"
]
}
]
Props Provided:
{
"serialNumber": "__SOME_UUID__",
"fuelQuantity": 9001,
"passengers": 50
}"
`;
6 changes: 2 additions & 4 deletions src/instantiation/validate/HelpfulJoiValidationError.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import Joi from 'joi';

// only importing types > dev dep
import type { ValidationError } from 'joi';

interface HelpfulJoiValidationErrorDetail {
message: string;
Expand All @@ -20,7 +18,7 @@ export class HelpfulJoiValidationError extends Error {
props,
domainObject,
}: {
error: Joi.ValidationError;
error: ValidationError;
props: any;
domainObject: string;
}) {
Expand Down
4 changes: 1 addition & 3 deletions src/instantiation/validate/HelpfulYupValidationError.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { ValidationError } from 'yup';

// only importing types > dev dep
import type { ValidationError } from 'yup';

export class HelpfulYupValidationError extends Error {
public details: string[];
Expand Down
33 changes: 33 additions & 0 deletions src/instantiation/validate/HelpfulZodValidationError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// only importing types > dev dep
import type { ZodError, ZodIssue } from 'zod';

export class HelpfulZodValidationError extends Error {
public details: ZodIssue[];

public props: any;

public domainObject: string;

constructor({
error,
props,
domainObject,
}: {
error: ZodError;
props: any;
domainObject: string;
}) {
const message = `
Errors were found while validating properties for domain object ${domainObject}.:
${JSON.stringify(error.errors, null, 2)}
Props Provided:
${JSON.stringify(props, null, 2)}
`.trim();
super(message);

this.details = error.errors;
this.props = props;
this.domainObject = domainObject;
}
}
37 changes: 29 additions & 8 deletions src/instantiation/validate/validate.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
// only importing types -> dev dep
import type { Schema as JoiSchema } from 'joi';
import type { ValidationError, Schema as YupSchema } from 'yup';
import type { ZodError, ZodSchema } from 'zod';

// only importing types -> dev dep
import { HelpfulJoiValidationError } from './HelpfulJoiValidationError';
import { HelpfulYupValidationError } from './HelpfulYupValidationError';
import { HelpfulZodValidationError } from './HelpfulZodValidationError';

export type SchemaOptions = YupSchema<any> | JoiSchema;
export type SchemaOptions<T> = ZodSchema<T> | YupSchema<T> | JoiSchema;

const isJoiSchema = (schema: SchemaOptions<any>): schema is JoiSchema => {
if ((schema as JoiSchema).$) return true; // joi schemas have `$`, zod and yup do not
return false;
};

const isJoiSchema = (schema: SchemaOptions): schema is JoiSchema => {
if ((schema as JoiSchema).$) return true; // joi schemas have `$`, yup does not
const isZodSchema = (schema: SchemaOptions<any>): schema is ZodSchema<any> => {
if ((schema as ZodSchema<any>)._refinement) return true; // only zod schemas have _refinement
return false;
};

const isYupSchema = (schema: SchemaOptions): schema is YupSchema<any> => {
// for now, since we only support two options, if its not a joi schema, it must be a yup schema
return !isJoiSchema(schema);
const isYupSchema = (schema: SchemaOptions<any>): schema is YupSchema<any> => {
if ((schema as YupSchema<any>).isValidSync) return true; // only yup schemas have this property
return false;
};

export const validate = ({
Expand All @@ -23,7 +30,7 @@ export const validate = ({
props,
}: {
domainObjectName: string;
schema: SchemaOptions;
schema: SchemaOptions<any>;
props: any;
}): void => {
if (isJoiSchema(schema)) {
Expand All @@ -49,4 +56,18 @@ export const validate = ({
throw error; // otherwise throw the error we got
}
}
if (isZodSchema(schema)) {
try {
schema.parse(props);
} catch (error) {
if (!(error instanceof Error)) throw error;
if (error.constructor.name === 'ZodError')
throw new HelpfulZodValidationError({
domainObject: domainObjectName,
error: error as ZodError,
props,
}); // if we got a yup validation error, make it more helpful
throw error; // otherwise throw the error we got
}
}
};

0 comments on commit b6c4000

Please sign in to comment.