diff --git a/package-lock.json b/package-lock.json index 9133d07..5e8b4c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,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" }, "engines": { "node": ">=8.0.0" @@ -20481,6 +20482,15 @@ "engines": { "node": ">=10" } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } }, "dependencies": { @@ -35981,6 +35991,12 @@ "synchronous-promise": "^2.0.13", "toposort": "^2.0.2" } + }, + "zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "dev": true } } } diff --git a/package.json b/package.json index 13b285c..340ba7a 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/instantiation/DomainObject.test.ts b/src/instantiation/DomainObject.test.ts index ea45123..082e4ea 100644 --- a/src/instantiation/DomainObject.test.ts +++ b/src/instantiation/DomainObject.test.ts @@ -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', () => { @@ -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 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 diff --git a/src/instantiation/DomainObject.ts b/src/instantiation/DomainObject.ts index f28bf6e..3a9e680 100644 --- a/src/instantiation/DomainObject.ts +++ b/src/instantiation/DomainObject.ts @@ -44,9 +44,9 @@ export class DomainObject> { * * 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; // todo: work around typescript's "static members cant reference class type parameters" /** * DomainObject.nested diff --git a/src/instantiation/__snapshots__/DomainObject.test.ts.snap b/src/instantiation/__snapshots__/DomainObject.test.ts.snap index cca473a..d739053 100644 --- a/src/instantiation/__snapshots__/DomainObject.test.ts.snap +++ b/src/instantiation/__snapshots__/DomainObject.test.ts.snap @@ -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 +}" +`; diff --git a/src/instantiation/validate/HelpfulJoiValidationError.ts b/src/instantiation/validate/HelpfulJoiValidationError.ts index 7090d60..7605e72 100644 --- a/src/instantiation/validate/HelpfulJoiValidationError.ts +++ b/src/instantiation/validate/HelpfulJoiValidationError.ts @@ -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; @@ -20,7 +18,7 @@ export class HelpfulJoiValidationError extends Error { props, domainObject, }: { - error: Joi.ValidationError; + error: ValidationError; props: any; domainObject: string; }) { diff --git a/src/instantiation/validate/HelpfulYupValidationError.ts b/src/instantiation/validate/HelpfulYupValidationError.ts index 494868f..b8bb8d8 100644 --- a/src/instantiation/validate/HelpfulYupValidationError.ts +++ b/src/instantiation/validate/HelpfulYupValidationError.ts @@ -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[]; diff --git a/src/instantiation/validate/HelpfulZodValidationError.ts b/src/instantiation/validate/HelpfulZodValidationError.ts new file mode 100644 index 0000000..768b7c0 --- /dev/null +++ b/src/instantiation/validate/HelpfulZodValidationError.ts @@ -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; + } +} diff --git a/src/instantiation/validate/validate.ts b/src/instantiation/validate/validate.ts index a08570c..1b37d48 100644 --- a/src/instantiation/validate/validate.ts +++ b/src/instantiation/validate/validate.ts @@ -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 | JoiSchema; +export type SchemaOptions = ZodSchema | YupSchema | JoiSchema; + +const isJoiSchema = (schema: SchemaOptions): 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): schema is ZodSchema => { + if ((schema as ZodSchema)._refinement) return true; // only zod schemas have _refinement return false; }; -const isYupSchema = (schema: SchemaOptions): schema is YupSchema => { - // 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): schema is YupSchema => { + if ((schema as YupSchema).isValidSync) return true; // only yup schemas have this property + return false; }; export const validate = ({ @@ -23,7 +30,7 @@ export const validate = ({ props, }: { domainObjectName: string; - schema: SchemaOptions; + schema: SchemaOptions; props: any; }): void => { if (isJoiSchema(schema)) { @@ -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 + } + } };