From 7cbeb670105d83f98171d57a2a14271a580fc3b7 Mon Sep 17 00:00:00 2001 From: Mike Cornwell Date: Thu, 2 Oct 2025 12:59:42 -0400 Subject: [PATCH 1/2] feat(zod): add support for zod properties and models --- package-lock.json | 16 ++++-- package.json | 5 +- src/lib.ts | 71 +++++++++++++++++++++++++ src/models.ts | 23 ++++++++ src/properties.ts | 14 +++++ src/types.ts | 18 +++++++ test/src/lib.test.ts | 101 ++++++++++++++++++++++++++++++++++++ test/src/properties.test.ts | 42 +++++++++++++++ test/src/validation.test.ts | 5 ++ 9 files changed, 290 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index b5089b9..3b85487 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,20 @@ { "name": "functional-models", - "version": "3.1.0", + "version": "3.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "functional-models", - "version": "3.1.0", + "version": "3.3.0", "license": "GPLV3", "dependencies": { "async-lock": "^1.3.0", "get-random-values": "^1.2.2", "lodash": "^4.17.21", "modern-async": "^2.0.4", - "openapi-types": "^12.1.3" + "openapi-types": "^12.1.3", + "zod": "^4.1.11" }, "devDependencies": { "@cucumber/cucumber": "^11.0.0", @@ -9169,6 +9170,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 3dba2d7..90e999a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "functional-models", - "version": "3.3.0", + "version": "3.4.0", "description": "Functional models is ooey gooey framework for building and using awesome models EVERYWHERE.", "main": "index.js", "types": "index.d.ts", @@ -88,6 +88,7 @@ "get-random-values": "^1.2.2", "lodash": "^4.17.21", "modern-async": "^2.0.4", - "openapi-types": "^12.1.3" + "openapi-types": "^12.1.3", + "zod": "^4.1.11" } } diff --git a/src/lib.ts b/src/lib.ts index 5b29b0b..2138862 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -1,7 +1,9 @@ import { OpenAPIV3 } from 'openapi-types' import kebabCase from 'lodash/kebabCase' +import flow from 'lodash/flow' import merge from 'lodash/merge' import get from 'lodash/get' +import { z, ZodType } from 'zod' import { ApiInfo, ApiInfoPartialRest, @@ -255,6 +257,74 @@ const populateApiInformation = ( return _fillOutRestInfo(pluralName, namespace, partial, nullRest) } +/** + * Create a zod schema generator for a property given its type and config. + * Returns a function that when called produces the zod schema. + */ +const createZodForProperty = + (propertyType: any, config?: PropertyConfig) => () => { + const myConfig: PropertyConfig = config || {} + const provided = myConfig.zod + if (provided) { + return provided as ZodType + } + + const _getZodForPropertyType = (pt: any) => { + switch (pt) { + case 'UniqueId': + return z.string() + case 'Date': + case 'Datetime': + return z.union([z.string(), z.date()]) + case 'Integer': + return z.number().int() + case 'Number': + return z.number() + case 'Boolean': + return z.boolean() + case 'Array': + return z.array(z.any()) + case 'Object': + return z.object() + case 'Email': + return z.email() + case 'Text': + case 'BigText': + return z.string() + case 'ModelReference': + return z.union([z.string(), z.number()]) + default: + return z.any() + } + } + + const baseSchema = _getZodForPropertyType(propertyType) + const choices = (config as any)?.choices + const schemaFromChoices = + choices && Array.isArray(choices) && choices.length > 0 + ? z.union(choices.map((c: any) => z.literal(c)) as any) + : baseSchema + + const finalSchema = flow([ + s => + typeof myConfig.minValue === 'number' ? s.min(myConfig.minValue) : s, + s => + typeof myConfig.maxValue === 'number' ? s.max(myConfig.maxValue) : s, + s => + typeof myConfig.minLength === 'number' ? s.min(myConfig.minLength) : s, + s => + typeof myConfig.maxLength === 'number' ? s.max(myConfig.maxLength) : s, + s => + myConfig.defaultValue !== undefined + ? s.default(myConfig.defaultValue) + : s, + s => (myConfig.required ? s : s.optional()), + s => (myConfig.description ? s.describe(myConfig.description) : s), + ])(schemaFromChoices) + + return finalSchema as ZodType + } + export { isReferencedProperty, getValueForModelInstance, @@ -269,4 +339,5 @@ export { populateApiInformation, NULL_ENDPOINT, NULL_METHOD, + createZodForProperty, } diff --git a/src/models.ts b/src/models.ts index 1b4b918..c53e4f0 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,4 +1,5 @@ import merge from 'lodash/merge' +import z, { ZodObject, ZodType } from 'zod' import { toJsonAble } from './serialization' import { createModelValidator } from './validation' import { @@ -43,6 +44,27 @@ const _convertOptions = ( return r } +const _createZod = ( + modelDefinition: MinimalModelDefinition +): ZodObject => { + if (modelDefinition.schema) { + if (modelDefinition.description) { + return modelDefinition.schema.describe(modelDefinition.description) + } + return modelDefinition.schema + } + const properties = Object.entries(modelDefinition.properties).reduce( + (acc, [key, property]) => { + const asProp = property as PropertyInstance + return merge(acc, { + [key]: asProp.getZod(), + }) + }, + {} as Record + ) + return z.object(properties) as ZodObject +} + const _toModelDefinition = ( minimal: MinimalModelDefinition ): ModelDefinition => { @@ -52,6 +74,7 @@ const _toModelDefinition = ( description: '', primaryKeyName: 'id', modelValidators: [], + schema: _createZod(minimal), ...minimal, } } diff --git a/src/properties.ts b/src/properties.ts index f537997..98ac711 100644 --- a/src/properties.ts +++ b/src/properties.ts @@ -1,5 +1,6 @@ import merge from 'lodash/merge' import get from 'lodash/get' +import { ZodType } from 'zod' import { arrayType, createPropertyValidator, @@ -47,6 +48,7 @@ import { getCommonNumberValidators, mergeValidators, isModelInstance, + createZodForProperty, } from './lib' const MAX_YEAR = 3000 @@ -162,6 +164,17 @@ const Property = < return _propertyValidatorWrapper } + // Build a zod schema for this property. If a zod schema is provided in the + // config it will be used as an override. + const getZod = (): ZodType => { + const provided = (config as any)?.zod + if (provided) { + return provided as ZodType + } + + return createZodForProperty(propertyType, config)() + } + const propertyInstance: PropertyInstance< TValue, TData, @@ -175,6 +188,7 @@ const Property = < getConstantValue, getPropertyType: () => propertyType, createGetter, + getZod, getValidator, } return propertyInstance diff --git a/src/types.ts b/src/types.ts index f8b804a..02dcfea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import * as openapi from 'openapi-types' +import { ZodObject, ZodType } from 'zod' /** * A function that returns the value, or just the value @@ -342,6 +343,10 @@ type PropertyInstance< TModelInstanceExtensions > ) => ValueGetter + /** + * Function that exposes a zod schema for this property. + */ + getZod: () => ZodType /** * Gets a validator for the property. This is not normally used. * Instead for validation look at {@link ModelInstance.validate} @@ -499,6 +504,15 @@ type PropertyConfigOptions> = Readonly< * Additional validators for the property. */ validators: readonly PropertyValidatorComponent[] + /** + * An optional zod schema for this property. If provided, it will be used as an + * override for the generated schema. + */ + zod?: ZodType + /** + * A short human readable description of the property for documentation. + */ + description?: string /** * The maximum length of the value. (Drives validation) */ @@ -733,6 +747,10 @@ type ModelDefinition = Readonly<{ * look at {@link ModelType.getApiInfo} */ api?: Partial + /** + * A zod schema for the model. + */ + schema: ZodObject }> /** diff --git a/test/src/lib.test.ts b/test/src/lib.test.ts index a6889c9..13183ff 100644 --- a/test/src/lib.test.ts +++ b/test/src/lib.test.ts @@ -3,8 +3,10 @@ import { buildValidEndpoint, isModelInstance, populateApiInformation, + createZodForProperty, } from '../../src/lib' import { ApiInfo, ApiMethod } from '../../src/index' +import z from 'zod' describe('/src/lib.ts', () => { describe('#populateApiInformation', () => { @@ -239,4 +241,103 @@ describe('/src/lib.ts', () => { assert.isTrue(actual) }) }) + describe('#createZodForProperty()', () => { + it('UniqueId: accepts string', () => { + const schema = createZodForProperty('UniqueId')() + const result = schema.safeParse('abc').success + assert.isTrue(result) + }) + it('Date: accepts Date object', () => { + const schema = createZodForProperty('Date')() + const result = schema.safeParse(new Date()).success + assert.isTrue(result) + }) + it('Integer: accepts integer', () => { + const schema = createZodForProperty('Integer')() + const result = schema.safeParse(5).success + assert.isTrue(result) + }) + it('Number: accepts float', () => { + const schema = createZodForProperty('Number')() + const result = schema.safeParse(1.23).success + assert.isTrue(result) + }) + it('Boolean: accepts true', () => { + const schema = createZodForProperty('Boolean')() + const result = schema.safeParse(true).success + assert.isTrue(result) + }) + it('Array: accepts empty array', () => { + const schema = createZodForProperty('Array')() + const result = schema.safeParse([]).success + assert.isTrue(result) + }) + it('Object: accepts object', () => { + const schema = createZodForProperty('Object')() + const result = schema.safeParse({}).success + assert.isTrue(result) + }) + it('Email: accepts valid email', () => { + const schema = createZodForProperty('Email')() + const result = schema.safeParse('a@b.com').success + assert.isTrue(result) + }) + it('Text: accepts string', () => { + const schema = createZodForProperty('Text')() + const result = schema.safeParse('hello').success + assert.isTrue(result) + }) + it('ModelReference: accepts number', () => { + const schema = createZodForProperty('ModelReference')() + const result = schema.safeParse(123).success + assert.isTrue(result) + }) + it('choices: allows listed choice', () => { + const schema = createZodForProperty('Text', { choices: ['a', 'b'] })() + const result = schema.safeParse('a').success + assert.isTrue(result) + }) + it('minValue: enforces minimum', () => { + const schema = createZodForProperty('Number', { minValue: 5 })() + const result = schema.safeParse(5).success + assert.isTrue(result) + }) + it('maxValue: enforces maximum', () => { + const schema = createZodForProperty('Number', { maxValue: 10 })() + const result = schema.safeParse(10).success + assert.isTrue(result) + }) + it('minLength: enforces min length', () => { + const schema = createZodForProperty('Text', { minLength: 2 })() + const result = schema.safeParse('ab').success + assert.isTrue(result) + }) + it('maxLength: enforces max length', () => { + const schema = createZodForProperty('Text', { maxLength: 3 })() + const result = schema.safeParse('abc').success + assert.isTrue(result) + }) + it('defaultValue: applies default on undefined', () => { + const schema = createZodForProperty('Text', { defaultValue: 'x' })() + const result = schema.parse(undefined) + assert.equal(result, 'x') + }) + it('required: when required true, undefined is invalid', () => { + const schema = createZodForProperty('Text', { required: true })() + const result = schema.safeParse(undefined).success + assert.isFalse(result) + }) + it('description: sets description metadata', () => { + const schema = createZodForProperty('Text', { + description: 'This is my description', + })() + const desc = schema.description + assert.equal(desc, 'This is my description') + }) + it('should use the zod that is passed in via config', () => { + const zod = z.string() + const schema = createZodForProperty('Text', { zod: zod })() + assert.equal(schema, zod) + }) + }) }) diff --git a/test/src/properties.test.ts b/test/src/properties.test.ts index 38013ab..5fee6d8 100644 --- a/test/src/properties.test.ts +++ b/test/src/properties.test.ts @@ -25,6 +25,7 @@ import { SingleTypeArrayProperty, YearProperty, PrimaryKeyUuidProperty, + UuidProperty, } from '../../src/properties' import { arrayType } from '../../src/validation' import { Model } from '../../src/models' @@ -39,6 +40,7 @@ import { PropertyType, PrimitiveValueType, } from '../../src/types' +import { z } from 'zod' chai.use(asPromised) @@ -1286,6 +1288,22 @@ describe('/src/properties.ts', () => { assert.equal(actual, expected) }) }) + describe('#getZod()', () => { + it('should use the zod that is passed in via config', () => { + const schema = z.string() + const instance = Property('OverrideMe', { + zod: schema, + }) + const actual = instance.getZod() + const expected = schema + assert.equal(actual, expected) + }) + it('should create a zod schema for the property type if no zod is passed in', () => { + const instance = Property('Text') + const actual = instance.getZod() + assert.isOk(actual.safeParse('test').success) + }) + }) describe('#getConfig()', () => { it('should provide the config that is passed in ', () => { // @ts-ignore @@ -1376,6 +1394,30 @@ describe('/src/properties.ts', () => { }) }) }) + describe('#UuidProperty()', () => { + it('should create a uuid if not provided and autoNow is set', async () => { + const instance = UuidProperty({ autoNow: true }) + const getter = instance.createGetter( + // @ts-ignore + undefined, + {}, + {} as unknown as ModelInstance + ) + const actual = await getter() + assert.isString(actual) + }) + it('should NOT create a uuid if not provided and autoNow is not set', async () => { + const instance = UuidProperty() + const getter = instance.createGetter( + // @ts-ignore + undefined, + {}, + {} as unknown as ModelInstance + ) + const actual = await getter() + assert.isUndefined(actual) + }) + }) describe('#PrimaryKeyUuidProperty()', () => { describe('#createGetter()', () => { it('should call createUuid only once even if called twice', async () => { diff --git a/test/src/validation.test.ts b/test/src/validation.test.ts index f6a80fc..05f0f55 100644 --- a/test/src/validation.test.ts +++ b/test/src/validation.test.ts @@ -769,6 +769,11 @@ describe('/src/validation.ts', () => { }) }) describe('#referenceTypeMatch()', () => { + it('should return undefined if a number is passed as a value', () => { + // @ts-ignore + const actual = referenceTypeMatch(TestModel1)(123) + assert.isUndefined(actual) + }) it('should return undefined if undefined is passed as a value', () => { const myModel = TestModel1.create({}) const actual = referenceTypeMatch(TestModel1)( From e3c8fce1bac32071159663a718befc8e3e232f28 Mon Sep 17 00:00:00 2001 From: Mike Cornwell Date: Thu, 2 Oct 2025 13:07:06 -0400 Subject: [PATCH 2/2] feat(zod): zod documentation --- README.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8402c6c..d0adb5e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ If so this is the framework for you. Functional Models empowers the creation of pure TypeScript/JavaScript function based models that can be used on a client, a web frontend, and/or a backend all the same time. Use this library to create models that can be reused EVERYWHERE. -Write validation code, metadata, property descriptions, and more! Functional Models is fully supportive of both TypeScript and JavaScript. In fact, the typescript empowers some really sweet dynamic type checking, and autocomplete! +Write validation code, metadata, property descriptions, and more! Functional Models is fully supportive of both TypeScript and JavaScript. In fact, the typescript empowers some really sweet dynamic type checking, and autocomplete! Now features Zod implementation for the model and properties. This framework was born out of the enjoyment and power of working with Django models, but, restricting their "god-like abilities" which can cause developers to make a spaghetti system that is nearly impossible to optimize or improve without starting from scratch. @@ -76,6 +76,7 @@ const { const Trucks = Model({ pluralName: 'Trucks', namespace: '@my-package/cars', + description: 'This is an optional description of my Trucks model.', properties: { id: PrimaryKeyUuidProperty(), make: TextProperty({ maxLength: 20, minLength: 3, required: true }), @@ -90,6 +91,10 @@ const Trucks = Model({ }, }) +// Get a Zod Schema for the Truck, automatically built! +const zodSchema = Trucks.getModelDefinition().schema +// Conver to OpenAPI with zod. + // Create an instance of the model. In this case, you don't need 'id', because it gets created automatically with UniquePropertyId() const myTruck = Trucks.create({ make: 'Ford', @@ -157,9 +162,11 @@ import { Model, DatetimeProperty, NumberProperty, + ObjectProperty, TextProperty, PrimaryKeyUuidProperty, } from 'functional-models' +import { z } from 'zod' // Create an object type. NOTE: Singular Uppercase type VehicleMake = { @@ -188,6 +195,10 @@ const VehicleMakes = Model({ }, }) +type ToolChest = Readonly<{ + toolCount: number +}> + // Create a model for the Vehicle type const Vehicles = Model({ pluralName: 'Vehicles', @@ -210,6 +221,14 @@ const Vehicles = Model({ }), make: ModelReferenceProperty(VehicleMakes, { required: true }), history: BigTextProperty({ required: false }), + // This overrides the automatic zod creation. Useful for complex properties, like objects. + toolChest: ObjectProperty({ + zod: z + .Object({ + toolCount: z.number().int(), + }) + .describe('An optional tool chest for the vehicle'), + }), lastModified: DatetimeProperty({ autoNow: true }), }, }) @@ -403,6 +422,8 @@ For additional information on the ORM system see: There are numerous properties that are supported out of the box that cover most data modeling needs. It is also very easy to create custom properties that encapsulate unique choices validation requirements, etc. +NOTE: While a simple zod is automatically built for each property, this can be overrided and any zod can be provided. Just a "zod" field to a property that you want to override. + ## List of Properties Out-Of-The-Box ### Dates