@radekmie
By Radosław Miernik · Published on · Comment on Reddit
The Zod validator is getting more and more traction. Whether it’s because it’s easy to use or out of the box type safety – people find it really appealing. I first heard of it back in 2020, when I found TypeBox – another great validator (schema) package with types in mind.
The difference between these two is fundamental – the latter relies on a well-known standard of JSON Schema, while the former introduces its own. I would call myself more of a “JSON Schema person”, but I agree that often it’s simply an overkill for some projects and implies a significant bundle size impact1.
For these simple cases, uniforms could use a simple yet powerful schema with a low footprint, just like Zod. Luckily, in contrary to other similar packages, it’s a pleasure. It involves a lot of instanceof
s, but it’s nice.
Shall we?
As the Bridge
abstract class is well-defined and typed, I decided to tackle it test-first. Let’s start with creating a new package in the monorepo, similar to the GraphQL integration. This includes the README.md
, package.json
, and a couple of tsconfig.json
s to make everything work.
Once everything is set, we can finally create our first test – nothing fancy, but enough to verify that everything (including running the tests) works. Finally, let’s implement the ZodBridge
itself:
With one more file simply re-exporting it, we can run npx jest --watch zod
and see that the test passes. From now on, we’ll work ground-up, implementing all of the Bridge
methods and additional features.
(To make this a true “devlog”, I’ll include the entire commits here as well.)Complete file-by-file diff.
"tslib": "^2.2.0",
"typescript": "4.2.4",
"universal-fetch": "1.0.0",
- "warning": "^4.0.0"
+ "warning": "^4.0.0",
+ "zod": "^3.0.0"
},
"engines": {
"npm": ">=7.0.0"
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/zod": {
+ "version": "3.19.1",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.19.1.tgz",
+ "integrity": "sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA==",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"node_modules/zwitch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
},
+ "zod": {
+ "version": "3.19.1",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.19.1.tgz",
+ "integrity": "sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA=="
+ },
"zwitch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz",
"tslib": "^2.2.0",
"typescript": "4.2.4",
"universal-fetch": "1.0.0",
- "warning": "^4.0.0"
+ "warning": "^4.0.0",
+ "zod": "^3.0.0"
},
"engines": {
"npm": ">=7.0.0"
+# uniforms-bridge-zod
+
+> Zod bridge for `uniforms`.
+
+## Install
+
+```sh
+$ npm install uniforms-bridge-zod
+```
+
+For more in depth documentation see [uniforms.tools](https://uniforms.tools).
+import * as uniformsZod from 'uniforms-bridge-zod';
+
+it('exports everything', () => {
+ expect(uniformsZod).toEqual({
+ default: expect.any(Function),
+ ZodBridge: expect.any(Function),
+ });
+});
+{
+ "name": "uniforms-bridge-zod",
+ "version": "3.10.0",
+ "license": "MIT",
+ "main": "./cjs/index.js",
+ "module": "./esm/index.js",
+ "sideEffects": false,
+ "description": "Zod schema bridge for uniforms.",
+ "repository": "https://github.com/vazco/uniforms/tree/master/packages/uniforms-bridge-zod",
+ "bugs": "https://github.com/vazco/uniforms/issues",
+ "funding": "https://github.com/vazco/uniforms?sponsor=1",
+ "keywords": [
+ "form",
+ "forms",
+ "react",
+ "schema",
+ "validation",
+ "zod"
+ ],
+ "files": [
+ "cjs/*.d.ts",
+ "cjs/*.js",
+ "esm/*.d.ts",
+ "esm/*.js",
+ "src/*.ts",
+ "src/*.tsx"
+ ],
+ "dependencies": {
+ "invariant": "^2.0.0",
+ "lodash": "^4.0.0",
+ "tslib": "^2.2.0",
+ "uniforms": "^3.10.0",
+ "zod": "^3.0.0"
+ }
+}
+import { Bridge } from 'uniforms';
+
+export default class ZodBridge extends Bridge {}
+export { default, default as ZodBridge } from './ZodBridge';
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": "src",
+ "outDir": "cjs",
+ "rootDir": "src",
+ "module": "CommonJS",
+ "tsBuildInfoFile": "../../node_modules/.cache/uniforms-bridge-zod.cjs.tsbuildinfo"
+ },
+ "include": ["src"],
+ "references": [{ "path": "../uniforms/tsconfig.cjs.json" }]
+}
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": "src",
+ "outDir": "esm",
+ "rootDir": "src",
+ "module": "ES6",
+ "tsBuildInfoFile": "../../node_modules/.cache/uniforms-bridge-zod.esm.tsbuildinfo"
+ },
+ "include": ["src"],
+ "references": [{ "path": "../uniforms/tsconfig.esm.json" }]
+}
{ "path": "packages/uniforms-bridge-simple-schema/tsconfig.esm.json" },
{ "path": "packages/uniforms-bridge-simple-schema-2/tsconfig.cjs.json" },
{ "path": "packages/uniforms-bridge-simple-schema-2/tsconfig.esm.json" },
+ { "path": "packages/uniforms-bridge-zod/tsconfig.cjs.json" },
+ { "path": "packages/uniforms-bridge-zod/tsconfig.esm.json" },
{ "path": "packages/uniforms-material/tsconfig.cjs.json" },
{ "path": "packages/uniforms-material/tsconfig.esm.json" },
{ "path": "packages/uniforms-mui/tsconfig.cjs.json" },
{ "path": "packages/uniforms-bridge-simple-schema/tsconfig.cjs.json" },
{ "path": "packages/uniforms-bridge-simple-schema-2/tsconfig.esm.json" },
{ "path": "packages/uniforms-bridge-simple-schema-2/tsconfig.cjs.json" },
+ { "path": "packages/uniforms-bridge-zod/tsconfig.esm.json" },
+ { "path": "packages/uniforms-bridge-zod/tsconfig.cjs.json" },
{ "path": "packages/uniforms-material/tsconfig.esm.json" },
{ "path": "packages/uniforms-material/tsconfig.cjs.json" },
{ "path": "packages/uniforms-mui/tsconfig.esm.json" },
Usually, uniforms render the entire form automatically, based only on the schema. To do so, we must know what fields are there at all. Every bridge provides such information through the getSubfields
method.
How do we implement it, though? All Zod object
schemas provide a .shape
property that we can use here. Additionally, we can memoize all getSubfields
calls, as the schemas are not dynamic in any way. Also, to preserve the entire schema type while making sure it’s an object schema, we introduce a generic parameter to our ZodBridge
:
+import { ZodBridge } from 'uniforms-bridge-zod';
+import { z } from 'zod';
+
+describe('ZodBridge', () => {
+ describe('#getSubfields', () => {
+ it('works with empty objects', () => {
+ const schema = z.object({});
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getSubfields()).toEqual([]);
+ });
+
+ it('works with non-empty objects', () => {
+ const schema = z.object({ a: z.string(), b: z.number() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getSubfields()).toEqual(['a', 'b']);
+ });
+ });
+});
+import memoize from 'lodash/memoize';
import { Bridge } from 'uniforms';
+import { z } from 'zod';
-export default class ZodBridge extends Bridge {}
+export default class ZodBridge<T extends z.ZodRawShape> extends Bridge {
+ constructor(public schema: z.ZodObject<T>) {
+ super();
+
+ this.getSubfields = memoize(this.getSubfields.bind(this));
+ }
+
+ getSubfields(name?: string): string[] {
+ if (name === undefined) {
+ return Object.keys(this.schema.shape);
+ }
+
+ // TODO.
+ return [];
+ }
+}
Once we have the list of all fields, we’ll render them one by one. However, to know whether a field contains nested ones (both arrays and objects do), we’ll need getSubfields
to work on specific fields too. Getting that information from a schema is easy, but we need to navigate down it somehow.
If we look at all of the Bridge
methods, there’s also getField
we’ll have to implement. It should return the subschema for a given field, which is precisely what we need. Let’s see how it works:
name: string
That was relatively easy, because all array
schemas have the .element
property, containing the entire subschema. And again, we can safely memoize its results. There are two uniforms-specific things in it, though.
Firstly, we have to handle $
s as any array element. It’s vital for the List*
components family. Secondly, we use joinName.unescape
to clean any escapes that joinName
could have added there. Nothing complicated, but probably unexpected for people who never worked with uniforms.
With getField
in place, the getSubfields
can now use it and remain simple:
name = ''
import { ZodBridge } from 'uniforms-bridge-zod';
-import { number, object, string } from 'zod';
+import { array, number, object, string } from 'zod';
describe('ZodBridge', () => {
+ describe('#getField', () => {
+ it('works with root schema', () => {
+ const schema = object({});
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getField('')).toBe(schema);
+ });
+
+ it('works with simple types', () => {
+ const schema = object({ a: string(), b: number() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getField('a')).toBe(schema.shape.a);
+ expect(bridge.getField('b')).toBe(schema.shape.b);
+ });
+
+ it('works with arrays', () => {
+ const schema = object({ a: array(array(string())) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getField('a')).toBe(schema.shape.a);
+ expect(bridge.getField('a.$')).toBe(schema.shape.a.element);
+ expect(bridge.getField('a.$.$')).toBe(schema.shape.a.element.element);
+ });
+
+ it('works with nested objects', () => {
+ const schema = object({ a: object({ b: object({ c: string() }) }) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getField('a')).toBe(schema.shape.a);
+ expect(bridge.getField('a.b')).toBe(schema.shape.a.shape.b);
+ expect(bridge.getField('a.b.c')).toBe(schema.shape.a.shape.b.shape.c);
+ });
+ });
+
describe('#getSubfields', () => {
it('works with empty objects', () => {
const schema = object({});
const bridge = new ZodBridge(schema);
expect(bridge.getSubfields()).toEqual(['a', 'b']);
});
+
+ it('works with simple types', () => {
+ const schema = object({ a: string(), b: number() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getSubfields('a')).toEqual([]);
+ expect(bridge.getSubfields('b')).toEqual([]);
+ });
+
+ it('works with arrays', () => {
+ const schema = object({ a: array(array(string())) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getSubfields('a')).toEqual(['$']);
+ expect(bridge.getSubfields('a.$')).toEqual(['$']);
+ expect(bridge.getSubfields('a.$.$')).toEqual([]);
+ });
+
+ it('works with nested objects', () => {
+ const schema = object({ a: object({ b: object({ c: string() }) }) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getSubfields('a')).toEqual(['b']);
+ expect(bridge.getSubfields('a.b')).toEqual(['c']);
+ expect(bridge.getSubfields('a.b.c')).toEqual([]);
+ });
});
});
+import invariant from 'invariant';
import memoize from 'lodash/memoize';
-import { Bridge } from 'uniforms';
-import { ZodObject, ZodRawShape } from 'zod';
+import { Bridge, joinName } from 'uniforms';
+import { ZodArray, ZodObject, ZodRawShape, ZodType } from 'zod';
+
+function fieldInvariant(name: string, condition: boolean): asserts condition {
+ invariant(condition, 'Field not found in schema: "%s"', name);
+}
export default class ZodBridge<T extends ZodRawShape> extends Bridge {
constructor(public schema: ZodObject<T>) {
super();
+ this.getField = memoize(this.getField.bind(this));
this.getSubfields = memoize(this.getSubfields.bind(this));
}
- getSubfields(name?: string): string[] {
- if (name === undefined) {
- return Object.keys(this.schema.shape);
+ getField(name: string) {
+ let field: ZodType = this.schema;
+ for (const key of joinName(null, name)) {
+ if (key === '$' || key === '' + parseInt(key, 10)) {
+ fieldInvariant(name, field instanceof ZodArray);
+ field = field.element;
+ } else {
+ fieldInvariant(name, field instanceof ZodObject);
+ field = field.shape[joinName.unescape(key)];
+ }
+ }
+
+ return field;
+ }
+
+ getSubfields(name = '') {
+ const field = this.getField(name);
+ if (field instanceof ZodArray) {
+ return ['$'];
+ }
+
+ if (field instanceof ZodObject) {
+ return Object.keys(field.shape);
}
- // TODO.
return [];
}
}
Once we have the list of fields and subfields, we can start rendering them. But which component should we use? The AutoField
component detector relies on fields’ data types, like Number
or String
, obtained from the getType
method. Let’s add it then:
name: string
Yet again, we navigate the schema with getField
, and list six conditions, one for each data type supported by uniforms. If none of them matches, throw an error with an appropriate message. (Ideally, we’d use switch
here, but it won’t work with instanceof
; .constructor
“trick” won’t work either.)Complete file-by-file diff.
import { ZodBridge } from 'uniforms-bridge-zod';
-import { array, number, object, string } from 'zod';
+import { array, boolean, date, number, object, string } from 'zod';
describe('ZodBridge', () => {
describe('#getField', () => {
expect(bridge.getSubfields('a.b.c')).toEqual([]);
});
});
+
+ describe('#getType', () => {
+ it('works with array', () => {
+ const schema = object({ a: array(array(string())) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getType('a')).toBe(Array);
+ });
+
+ it('works with boolean', () => {
+ const schema = object({ a: boolean() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getType('a')).toBe(Boolean);
+ });
+
+ it('works with date', () => {
+ const schema = object({ a: date() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getType('a')).toBe(Date);
+ });
+
+ it('works with number', () => {
+ const schema = object({ a: number() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getType('a')).toBe(Number);
+ });
+
+ it('works with object', () => {
+ const schema = object({ a: object({ b: object({ c: string() }) }) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getType('a')).toBe(Object);
+ });
+
+ it('works with string', () => {
+ const schema = object({ a: string() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getType('a')).toBe(String);
+ });
+ });
});
import invariant from 'invariant';
import memoize from 'lodash/memoize';
import { Bridge, joinName } from 'uniforms';
-import { ZodArray, ZodObject, ZodRawShape, ZodType } from 'zod';
+import {
+ ZodArray,
+ ZodBoolean,
+ ZodDate,
+ ZodNumber,
+ ZodObject,
+ ZodRawShape,
+ ZodString,
+ ZodType,
+} from 'zod';
function fieldInvariant(name: string, condition: boolean): asserts condition {
invariant(condition, 'Field not found in schema: "%s"', name);
return [];
}
+
+ getType(name: string) {
+ const field = this.getField(name);
+ if (field instanceof ZodArray) {
+ return Array;
+ }
+
+ if (field instanceof ZodBoolean) {
+ return Boolean;
+ }
+
+ if (field instanceof ZodDate) {
+ return Date;
+ }
+
+ if (field instanceof ZodNumber) {
+ return Number;
+ }
+
+ if (field instanceof ZodObject) {
+ return Object;
+ }
+
+ if (field instanceof ZodString) {
+ return String;
+ }
+
+ invariant(false, 'Field "%s" has an unknown type', name);
+ }
}
We already know which component we should render, so let’s focus on validation itself for a second. Like most modern schemas, Zod supports both synchronous and asynchronous validation (and so do uniforms).
However, as the async validation is done through .refine
calls (i.e., using functions), it’s impossible to know statically, whether a schema is async or not. For now, I decided to make it work only with sync validation and get feedback about it from early adopters. In the end, it’s as simple as adding another flag.
How does a bridge validate a model? It doesn’t. It has a getValidator
method, which returns a validator function. The API looks like it, to support validator options. The code is again trivial:
expect(bridge.getType('a')).toBe(String);
});
});
+
+ describe('#getValidator', () => {
+ it('is a function', () => {
+ const schema = object({});
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getValidator()).toEqual(expect.any(Function));
+ });
+ });
});
invariant(false, 'Field "%s" has an unknown type', name);
}
+
+ getValidator() {
+ return (model: Record<string, unknown>) => {
+ // TODO: What about async schemas?
+ // eslint-disable-next-line react/no-this-in-sfc
+ const result = this.schema.safeParse(model);
+ return result.success ? null : result.error;
+ };
+ }
}
With the validation in place, we can now work on the error handling. The validation flow in uniforms is simple: the submission handler or the validator function (obtained from the getValidator
method) returns an error that can be used elsewhere. (There’s also an option to set the error explicitly.)
The first error-related function is getError
, which extracts a field-specific part of the error. For now, I decided to force the end-users to wrap their errors in ZodError
objects if they want to display them near a field, but it’s a rather sane requirement. Here’s the code:
name: string, error: unknown
What does the joinName
call do here? The name
parameter is a stringified field name, i.e., nested fields are already concatenated. Zod, on the other hand, provides us with an array of keys (or indexes). We do it this way, because it’s easier to serialize these paths and compare them as strings.Complete file-by-file diff.
import { array, boolean, date, number, object, string } from 'zod';
describe('ZodBridge', () => {
+ describe('#getError', () => {
+ it('works without error', () => {
+ const schema = object({ a: string() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getError('a', null)).toBe(null);
+ expect(bridge.getError('a', undefined)).toBe(null);
+ });
+
+ it('works with simple types', () => {
+ const schema = object({ a: string(), b: number() });
+ const bridge = new ZodBridge(schema);
+ const error = bridge.getValidator()({});
+ expect(bridge.getError('a', error)).toBe(error?.issues[0]);
+ expect(bridge.getError('b', error)).toBe(error?.issues[1]);
+ });
+
+ it('works with arrays', () => {
+ const schema = object({ a: array(array(string())) });
+ const bridge = new ZodBridge(schema);
+ const error = bridge.getValidator()({ a: [['x', 'y', 0], [1]] });
+ expect(bridge.getError('a', error)).toBe(null);
+ expect(bridge.getError('a.0', error)).toBe(null);
+ expect(bridge.getError('a.0.0', error)).toBe(null);
+ expect(bridge.getError('a.0.1', error)).toBe(null);
+ expect(bridge.getError('a.0.2', error)).toBe(error?.issues[0]);
+ expect(bridge.getError('a.1.0', error)).toBe(error?.issues[1]);
+ });
+
+ it('works with nested objects', () => {
+ const schema = object({ a: object({ b: object({ c: string() }) }) });
+ const bridge = new ZodBridge(schema);
+ const error = bridge.getValidator()({ a: { b: { c: 1 } } });
+ expect(bridge.getError('a', error)).toBe(null);
+ expect(bridge.getError('a.b', error)).toBe(null);
+ expect(bridge.getError('a.b.c', error)).toBe(error?.issues[0]);
+ });
+ });
+
describe('#getField', () => {
it('works with root schema', () => {
const schema = object({});
ZodArray,
ZodBoolean,
ZodDate,
+ ZodError,
ZodNumber,
ZodObject,
ZodRawShape,
this.getSubfields = memoize(this.getSubfields.bind(this));
}
+ getError(name: string, error: unknown) {
+ if (!(error instanceof ZodError)) {
+ return null;
+ }
+
+ return error.issues.find(issue => name === joinName(issue.path)) || null;
+ }
+
getField(name: string) {
let field: ZodType = this.schema;
for (const key of joinName(null, name)) {
Next, there’s getErrorMessage
, which extracts the field-specific error message of the error. Thanks to getError
, a trivial one:
name: string, error: unknown
const schema = object({ a: string(), b: number() });
const bridge = new ZodBridge(schema);
const error = bridge.getValidator()({});
- expect(bridge.getError('a', error)).toBe(error?.issues[0]);
- expect(bridge.getError('b', error)).toBe(error?.issues[1]);
+ const issues = error?.issues;
+ expect(bridge.getError('a', error)).toBe(issues?.[0]);
+ expect(bridge.getError('b', error)).toBe(issues?.[1]);
});
it('works with arrays', () => {
const schema = object({ a: array(array(string())) });
const bridge = new ZodBridge(schema);
const error = bridge.getValidator()({ a: [['x', 'y', 0], [1]] });
+ const issues = error?.issues;
expect(bridge.getError('a', error)).toBe(null);
expect(bridge.getError('a.0', error)).toBe(null);
expect(bridge.getError('a.0.0', error)).toBe(null);
expect(bridge.getError('a.0.1', error)).toBe(null);
- expect(bridge.getError('a.0.2', error)).toBe(error?.issues[0]);
- expect(bridge.getError('a.1.0', error)).toBe(error?.issues[1]);
+ expect(bridge.getError('a.0.2', error)).toBe(issues?.[0]);
+ expect(bridge.getError('a.1.0', error)).toBe(issues?.[1]);
});
it('works with nested objects', () => {
const schema = object({ a: object({ b: object({ c: string() }) }) });
const bridge = new ZodBridge(schema);
const error = bridge.getValidator()({ a: { b: { c: 1 } } });
+ const issues = error?.issues;
expect(bridge.getError('a', error)).toBe(null);
expect(bridge.getError('a.b', error)).toBe(null);
- expect(bridge.getError('a.b.c', error)).toBe(error?.issues[0]);
+ expect(bridge.getError('a.b.c', error)).toBe(issues?.[0]);
+ });
+ });
+
+ describe('#getErrorMessage', () => {
+ it('works without error', () => {
+ const schema = object({ a: string() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getErrorMessage('a', null)).toBe('');
+ expect(bridge.getErrorMessage('a', undefined)).toBe('');
+ });
+
+ it('works with simple types', () => {
+ const schema = object({ a: string(), b: number() });
+ const bridge = new ZodBridge(schema);
+ const error = bridge.getValidator()({});
+ const issues = error?.issues;
+ expect(bridge.getErrorMessage('a', error)).toBe(issues?.[0].message);
+ expect(bridge.getErrorMessage('b', error)).toBe(issues?.[1].message);
+ });
+
+ it('works with arrays', () => {
+ const schema = object({ a: array(array(string())) });
+ const bridge = new ZodBridge(schema);
+ const error = bridge.getValidator()({ a: [['x', 'y', 0], [1]] });
+ const issues = error?.issues;
+ expect(bridge.getErrorMessage('a', error)).toBe('');
+ expect(bridge.getErrorMessage('a.0', error)).toBe('');
+ expect(bridge.getErrorMessage('a.0.0', error)).toBe('');
+ expect(bridge.getErrorMessage('a.0.1', error)).toBe('');
+ expect(bridge.getErrorMessage('a.0.2', error)).toBe(issues?.[0].message);
+ expect(bridge.getErrorMessage('a.1.0', error)).toBe(issues?.[1].message);
+ });
+
+ it('works with nested objects', () => {
+ const schema = object({ a: object({ b: object({ c: string() }) }) });
+ const bridge = new ZodBridge(schema);
+ const error = bridge.getValidator()({ a: { b: { c: 1 } } });
+ const issues = error?.issues;
+ expect(bridge.getErrorMessage('a', error)).toBe('');
+ expect(bridge.getErrorMessage('a.b', error)).toBe('');
+ expect(bridge.getErrorMessage('a.b.c', error)).toBe(issues?.[0].message);
});
});
return error.issues.find(issue => name === joinName(issue.path)) || null;
}
+ getErrorMessage(name: string, error: unknown) {
+ return this.getError(name, error)?.message || '';
+ }
+
getField(name: string) {
let field: ZodType = this.schema;
for (const key of joinName(null, name)) {
Lastly, there’s getErrorMessages
, which computes a list of all error messages of an error. Most importantly, it should handle the generic errors that are not specific to one of the fields (e.g., submission error).
Unfortunately, Zod doesn’t provide full error messages, i.e., they don’t include the field name. For now, I decided to ignore it entirely and gather feedback later, as we have multiple ways of handling it.
error: unknown
});
});
+ describe('#getErrorMessages', () => {
+ it('works without error', () => {
+ const schema = object({ a: string() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getErrorMessages(null)).toEqual([]);
+ expect(bridge.getErrorMessages(undefined)).toEqual([]);
+ });
+
+ it('works with generic error', () => {
+ const schema = object({ a: string() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getErrorMessages(new Error('Error'))).toEqual(['Error']);
+ });
+
+ it('works with simple types', () => {
+ const schema = object({ a: string(), b: number() });
+ const bridge = new ZodBridge(schema);
+ const error = bridge.getValidator()({});
+ const messages = error?.issues?.map(issue => issue.message);
+ expect(bridge.getErrorMessages(error)).toEqual(messages);
+ });
+
+ it('works with arrays', () => {
+ const schema = object({ a: array(array(string())) });
+ const bridge = new ZodBridge(schema);
+ const error = bridge.getValidator()({ a: [['x', 'y', 0], [1]] });
+ const messages = error?.issues?.map(issue => issue.message);
+ expect(bridge.getErrorMessages(error)).toEqual(messages);
+ });
+
+ it('works with nested objects', () => {
+ const schema = object({ a: object({ b: object({ c: string() }) }) });
+ const bridge = new ZodBridge(schema);
+ const error = bridge.getValidator()({ a: { b: { c: 1 } } });
+ const messages = error?.issues?.map(issue => issue.message);
+ expect(bridge.getErrorMessages(error)).toEqual(messages);
+ });
+ });
+
describe('#getField', () => {
it('works with root schema', () => {
const schema = object({});
return this.getError(name, error)?.message || '';
}
+ getErrorMessages(error: unknown) {
+ if (error instanceof ZodError) {
+ // TODO: There's no information which field caused which error. We could
+ // do some generic prefixing, e.g., `{name}: {message}`.
+ return error.issues.map(issue => issue.message);
+ }
+
+ if (error instanceof Error) {
+ return [error.message];
+ }
+
+ return [];
+ }
+
getField(name: string) {
let field: ZodType = this.schema;
for (const key of joinName(null, name)) {
The error handling is now covered, so let’s get back to rendering. The most important prop that a field receives is its value. To calculate the initial one, the bridge has to implement a getInitialValue
method:
name: string : unknown
The logic is simple: arrays have to know their element’s value, and objects iterate over all of their properties. If you’re familiar with Zod, you’ll notice that we didn’t cover .default
here – we’ll take care of it later.Complete file-by-file diff.
});
});
+ describe('#getInitialValue', () => {
+ it('works with array', () => {
+ const schema = object({ a: array(array(string())) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getInitialValue('a')).toEqual([]);
+ });
+
+ it('works with array (min length)', () => {
+ const schema = object({ a: array(array(string()).min(1)).min(2) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getInitialValue('a')).toEqual([[], []]);
+ });
+
+ it('works with boolean', () => {
+ const schema = object({ a: boolean() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getInitialValue('a')).toEqual(undefined);
+ });
+
+ it('works with date', () => {
+ const schema = object({ a: date() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getInitialValue('a')).toEqual(undefined);
+ });
+
+ it('works with number', () => {
+ const schema = object({ a: number() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getInitialValue('a')).toEqual(undefined);
+ });
+
+ it('works with object', () => {
+ const schema = object({ a: object({ b: object({ c: string() }) }) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getInitialValue('a')).toEqual({ b: {} });
+ });
+
+ it('works with string', () => {
+ const schema = object({ a: string() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getInitialValue('a')).toEqual(undefined);
+ });
+ });
+
describe('#getSubfields', () => {
it('works with empty objects', () => {
const schema = object({});
return field;
}
+ // TODO: The `fieldProps` argument will be removed in v4. See
+ // https://github.com/vazco/uniforms/issues/1048 for details.
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ getInitialValue(name: string, fieldProps?: Record<string, unknown>): unknown {
+ const field = this.getField(name);
+ if (field instanceof ZodArray) {
+ const item = this.getInitialValue(joinName(name, '$'));
+ if (item === undefined) {
+ return [];
+ }
+
+ const length = field._def.minLength?.value || 0;
+ return Array.from({ length }, () => item);
+ }
+
+ if (field instanceof ZodObject) {
+ const value: Record<string, unknown> = {};
+ this.getSubfields(name).forEach(key => {
+ const initialValue = this.getInitialValue(joinName(name, key));
+ if (initialValue !== undefined) {
+ value[key] = initialValue;
+ }
+ });
+ return value;
+ }
+
+ return undefined;
+ }
+
getSubfields(name = '') {
const field = this.getField(name);
if (field instanceof ZodArray) {
The last part needed to successfully render a field is to get it the other props it needs. A bridge can provide any number of through the getProps
method, but two are actually crucial: label
and required
.
There’s nothing in Zod that’d provide us the former, so I decided to mimic the JSON Schema integration. The latter, similarly to .default
, will be taken care of later, as it involves more changes:
name: string
});
});
+ describe('#getProps', () => {
+ it('works with array', () => {
+ const schema = object({ a: array(array(string())) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({ label: 'A', required: true });
+ expect(bridge.getProps('a.0')).toEqual({ label: '0', required: true });
+ expect(bridge.getProps('a.0.0')).toEqual({ label: '0', required: true });
+ });
+
+ it('works with boolean', () => {
+ const schema = object({ a: boolean() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({ label: 'A', required: true });
+ });
+
+ it('works with date', () => {
+ const schema = object({ a: date() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({ label: 'A', required: true });
+ });
+
+ it('works with number', () => {
+ const schema = object({ a: number() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({ label: 'A', required: true });
+ });
+
+ it('works with object', () => {
+ const schema = object({ a: object({ b: object({ c: string() }) }) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({ label: 'A', required: true });
+ expect(bridge.getProps('a.b')).toEqual({ label: 'B', required: true });
+ expect(bridge.getProps('a.b.c')).toEqual({ label: 'C', required: true });
+ });
+
+ it('works with string', () => {
+ const schema = object({ a: string() });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({ label: 'A', required: true });
+ });
+ });
+
describe('#getSubfields', () => {
it('works with empty objects', () => {
const schema = object({});
import invariant from 'invariant';
+import lowerCase from 'lodash/lowerCase';
import memoize from 'lodash/memoize';
+import upperFirst from 'lodash/upperFirst';
import { Bridge, joinName } from 'uniforms';
import {
ZodArray,
return undefined;
}
+ // TODO: The `props` argument could be removed in v4, just like in the
+ // `getInitialValue` function.
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ getProps(name: string, props?: Record<string, unknown>) {
+ return {
+ label: upperFirst(lowerCase(joinName(null, name).slice(-1)[0])),
+ // TODO: Handle optional values.
+ required: true,
+ };
+ }
+
getSubfields(name = '') {
const field = this.getField(name);
if (field instanceof ZodArray) {
At this point, our Zod integration is completely usable, and it’d be nice to test it in the browser. We’ll add two more example schemas to the uniforms’ playground and run it. It feels like we did so much work already, but we’re at merely 161 lines of code!Complete file-by-file diff.
)
`,
+ 'Address (Zod)': preset`
+ new ZodBridge(
+ z.object({
+ // TODO: Make it optional.
+ city: z.string().max(50),
+ state: z.string(),
+ street: z.string().max(100),
+ zip: z.string().regex(/^[0-9]{5}$/),
+ })
+ )
+ `,
+
'All Fields (SimpleSchema)': preset`
new SimpleSchema2Bridge(
new SimpleSchema({
})
)
`,
+
+ 'All Fields (Zod)': preset`
+ new ZodBridge(
+ z.object({
+ text: z.string(),
+ num: z.number(),
+ bool: z.boolean(),
+ nested: z.object({ text: z.string() }),
+ date: z.date(),
+ // TODO: Custom label and placeholder.
+ list: z.array(z.string()),
+ // TODO: Enums.
+ select: z.string(),
+ // TODO: Enums with custom props.
+ radio: z.string(),
+ })
+ )
+ `,
};
export default presets;
import { GraphQLBridge } from 'uniforms-bridge-graphql';
import { JSONSchemaBridge } from 'uniforms-bridge-json-schema';
import { SimpleSchema2Bridge } from 'uniforms-bridge-simple-schema-2';
+import { ZodBridge } from 'uniforms-bridge-zod';
+import { z } from 'zod';
import presets from './presets';
import { themes } from './universal';
scope.JSONSchemaBridge = JSONSchemaBridge;
scope.SimpleSchema = SimpleSchema;
scope.SimpleSchema2Bridge = SimpleSchema2Bridge;
+scope.ZodBridge = ZodBridge;
scope.buildASTSchema = buildASTSchema;
scope.parse = parse;
+scope.z = z;
// Dynamic field error.
MessageBox.defaults({ messages: { en: { syntax: '' } } });
"uniforms-bridge-json-schema": "^3.10.0-rc.0",
"uniforms-bridge-simple-schema": "^3.10.0-rc.0",
"uniforms-bridge-simple-schema-2": "^3.10.0-rc.0",
+ "uniforms-bridge-zod": "^3.10.0-rc.0",
"uniforms-material": "^3.10.0-rc.0",
"uniforms-mui": "^3.10.0-rc.0",
"uniforms-semantic": "^3.10.0-rc.0",
As we implemented all of the methods, now let’s make sure we can actually handle all of the common schemas. We ignored a few schema types until now, and one of them were enums.
Zod differentiates them into .enum
and .nativeEnum
. The former is simply an array of strings; the latter is an object with string or numeric values (that’s how TypeScript enum
s work). Luckily, even if the latter includes numeric values, we’re allowed to use strings at all times (i.e., keys), meaning that we can treat all enums as strings in our integration.
First, we need to handle them in the getInitialValue
method, treating some of the enum values as the initial one:
if field instanceof ZodEnum
if field instanceof ZodNativeEnum
Next, resolve their type correctly in getType
:
if
field instanceof ZodEnum ||
field instanceof ZodNativeEnum ||
field instanceof ZodString
// This is no longer needed.
if field instanceof ZodString
And finally, calculate the allowedValues
prop in getProps
, to render them using dropdowns and not text inputs:
name: string
import { ZodBridge } from 'uniforms-bridge-zod';
-import { array, boolean, date, number, object, string } from 'zod';
+import {
+ array,
+ boolean,
+ date,
+ enum as enum_,
+ nativeEnum,
+ number,
+ object,
+ string,
+} from 'zod';
describe('ZodBridge', () => {
describe('#getError', () => {
expect(bridge.getInitialValue('a')).toEqual(undefined);
});
+ it('works with enum (array)', () => {
+ const schema = object({ a: enum_(['x', 'y', 'z']) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getInitialValue('a')).toEqual('x');
+ });
+
+ it('works with enum (native, numbers)', () => {
+ enum Test {
+ x,
+ y,
+ z = 'a',
+ }
+
+ const schema = object({ a: nativeEnum(Test) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getInitialValue('a')).toEqual('x');
+ });
+
+ it('works with enum (native, numbers)', () => {
+ enum Test {
+ x,
+ y,
+ z,
+ }
+
+ const schema = object({ a: nativeEnum(Test) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getInitialValue('a')).toEqual('x');
+ });
+
+ it('works with enum (native, string)', () => {
+ enum Test {
+ x = 'x',
+ y = 'y',
+ z = 'z',
+ }
+
+ const schema = object({ a: nativeEnum(Test) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getInitialValue('a')).toEqual('x');
+ });
+
it('works with number', () => {
const schema = object({ a: number() });
const bridge = new ZodBridge(schema);
expect(bridge.getProps('a')).toEqual({ label: 'A', required: true });
});
+ it('works with enum (array)', () => {
+ const schema = object({ a: enum_(['x', 'y', 'z']) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({
+ allowedValues: ['x', 'y', 'z'],
+ label: 'A',
+ required: true,
+ });
+ });
+
+ it('works with enum (native, mixed)', () => {
+ enum Test {
+ x,
+ y,
+ z = 'a',
+ }
+
+ const schema = object({ a: nativeEnum(Test) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({
+ allowedValues: ['x', 'y', 'a'],
+ label: 'A',
+ required: true,
+ });
+ });
+
+ it('works with enum (native, number)', () => {
+ enum Test {
+ x,
+ y,
+ z,
+ }
+
+ const schema = object({ a: nativeEnum(Test) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({
+ allowedValues: ['x', 'y', 'z'],
+ label: 'A',
+ required: true,
+ });
+ });
+
+ it('works with enum (native, string)', () => {
+ enum Test {
+ x = 'x',
+ y = 'y',
+ z = 'z',
+ }
+
+ const schema = object({ a: nativeEnum(Test) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({
+ allowedValues: ['x', 'y', 'z'],
+ label: 'A',
+ required: true,
+ });
+ });
+
it('works with number', () => {
const schema = object({ a: number() });
const bridge = new ZodBridge(schema);
expect(bridge.getType('a')).toBe(Date);
});
+ it('works with enum (array)', () => {
+ const schema = object({ a: enum_(['x', 'y', 'z']) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getType('a')).toBe(String);
+ });
+
+ it('works with enum (native, mixed)', () => {
+ enum Test {
+ x,
+ y,
+ z = 'a',
+ }
+
+ const schema = object({ a: nativeEnum(Test) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getType('a')).toBe(String);
+ });
+
+ it('works with enum (native, number)', () => {
+ enum Test {
+ x,
+ y,
+ z,
+ }
+
+ const schema = object({ a: nativeEnum(Test) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getType('a')).toBe(String);
+ });
+
+ it('works with enum (native, string)', () => {
+ enum Test {
+ x = 'x',
+ y = 'y',
+ z = 'z',
+ }
+
+ const schema = object({ a: nativeEnum(Test) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getType('a')).toBe(String);
+ });
+
it('works with number', () => {
const schema = object({ a: number() });
const bridge = new ZodBridge(schema);
ZodArray,
ZodBoolean,
ZodDate,
+ ZodEnum,
ZodError,
+ ZodNativeEnum,
ZodNumber,
ZodObject,
ZodRawShape,
invariant(condition, 'Field not found in schema: "%s"', name);
}
+function isNativeEnumValue(value: unknown) {
+ return typeof value !== 'number';
+}
+
export default class ZodBridge<T extends ZodRawShape> extends Bridge {
constructor(public schema: ZodObject<T>) {
super();
return Array.from({ length }, () => item);
}
+ if (field instanceof ZodEnum) {
+ return field.options[0];
+ }
+
+ if (field instanceof ZodNativeEnum) {
+ return Object.values(field.enum)[0];
+ }
+
if (field instanceof ZodObject) {
const value: Record<string, unknown> = {};
this.getSubfields(name).forEach(key => {
// TODO: The `props` argument could be removed in v4, just like in the
// `getInitialValue` function.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
- getProps(name: string, props?: Record<string, unknown>) {
- return {
+ getProps(name: string, fieldProps?: Record<string, unknown>) {
+ const field = this.getField(name);
+ const props: Record<string, unknown> = {
label: upperFirst(lowerCase(joinName(null, name).slice(-1)[0])),
// TODO: Handle optional values.
required: true,
};
+
+ if (field instanceof ZodEnum) {
+ props.allowedValues = field.options;
+ }
+
+ if (field instanceof ZodNativeEnum) {
+ // Native enums have both numeric and string values.
+ props.allowedValues = Object.values(field.enum).filter(isNativeEnumValue);
+ }
+
+ return props;
}
getSubfields(name = '') {
return Date;
}
+ if (
+ field instanceof ZodEnum ||
+ field instanceof ZodNativeEnum ||
+ field instanceof ZodString
+ ) {
+ return String;
+ }
+
if (field instanceof ZodNumber) {
return Number;
}
return Object;
}
- if (field instanceof ZodString) {
- return String;
- }
-
invariant(false, 'Field "%s" has an unknown type', name);
}
date: z.date(),
// TODO: Custom label and placeholder.
list: z.array(z.string()),
- // TODO: Enums.
- select: z.string(),
+ select: z.enum(['a', 'b']),
// TODO: Enums with custom props.
- radio: z.string(),
+ radio: z.enum(['a', 'b']),
})
)
`,
As suggested earlier, optional fields require more attention than expected. It’s because Zod wraps the non-optional schema in a ZodOptional
one, forcing us to unwrap them more than once. Unwrapping is simple and has to be added in getField
, getSubfields
, and getType
:
if field instanceof ZodOptional
Also, we can finally handle the required
prop in getProps
correctly:
if field instanceof ZodOptional
nativeEnum,
number,
object,
+ optional,
string,
} from 'zod';
expect(bridge.getField('a.b')).toBe(schema.shape.a.shape.b);
expect(bridge.getField('a.b.c')).toBe(schema.shape.a.shape.b.shape.c);
});
+
+ it('works with optional', () => {
+ const schema = object({ a: optional(object({ b: string() })) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getField('a')).toBe(schema.shape.a);
+ expect(bridge.getField('a.b')).toBe(schema.shape.a.unwrap().shape.b);
+ });
});
describe('#getInitialValue', () => {
expect(bridge.getInitialValue('a')).toEqual({ b: {} });
});
+ it('works with optional', () => {
+ const schema = object({ a: optional(string()) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getInitialValue('a')).toEqual(undefined);
+ });
+
it('works with string', () => {
const schema = object({ a: string() });
const bridge = new ZodBridge(schema);
expect(bridge.getProps('a.b.c')).toEqual({ label: 'C', required: true });
});
+ it('works with optional', () => {
+ const schema = object({ a: optional(string()) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({ label: 'A', required: false });
+ });
+
it('works with string', () => {
const schema = object({ a: string() });
const bridge = new ZodBridge(schema);
expect(bridge.getSubfields('a.b')).toEqual(['c']);
expect(bridge.getSubfields('a.b.c')).toEqual([]);
});
+
+ it('works with optional', () => {
+ const schema = object({ a: optional(object({ b: string() })) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getSubfields('a')).toEqual(['b']);
+ expect(bridge.getSubfields('a.b')).toEqual([]);
+ });
});
describe('#getType', () => {
expect(bridge.getType('a')).toBe(Object);
});
+ it('works with optional', () => {
+ const schema = object({ a: optional(string()) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getType('a')).toBe(String);
+ });
+
it('works with string', () => {
const schema = object({ a: string() });
const bridge = new ZodBridge(schema);
ZodNativeEnum,
ZodNumber,
ZodObject,
+ ZodOptional,
ZodRawShape,
ZodString,
ZodType,
getField(name: string) {
let field: ZodType = this.schema;
for (const key of joinName(null, name)) {
+ if (field instanceof ZodOptional) {
+ field = field.unwrap();
+ }
+
if (key === '$' || key === '' + parseInt(key, 10)) {
fieldInvariant(name, field instanceof ZodArray);
field = field.element;
// `getInitialValue` function.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getProps(name: string, fieldProps?: Record<string, unknown>) {
- const field = this.getField(name);
const props: Record<string, unknown> = {
label: upperFirst(lowerCase(joinName(null, name).slice(-1)[0])),
- // TODO: Handle optional values.
required: true,
};
+ let field = this.getField(name);
+ if (field instanceof ZodOptional) {
+ field = field.unwrap();
+ props.required = false;
+ }
+
if (field instanceof ZodEnum) {
props.allowedValues = field.options;
}
}
getSubfields(name = '') {
- const field = this.getField(name);
+ let field = this.getField(name);
+ if (field instanceof ZodOptional) {
+ field = field.unwrap();
+ }
+
if (field instanceof ZodArray) {
return ['$'];
}
}
getType(name: string) {
- const field = this.getField(name);
+ let field = this.getField(name);
+ if (field instanceof ZodOptional) {
+ field = field.unwrap();
+ }
+
if (field instanceof ZodArray) {
return Array;
}
Similarly to optional fields, fields with default values (i.e., created using the .default
method) are wrapped in a ZodDefault
schema object. That means, we have to adjust our unwrapping in getField
, getProps
, getSubfields
, and getType
slightly:
-if (field instanceof ZodOptional) {
+if (field instanceof ZodDefault) {
+ field = field.removeDefault();
+ props.required = false;
+} else if (field instanceof ZodOptional) {
field = field.unwrap();
However, it allows us to make getInitialValues
smarter and configurable:
if field instanceof ZodDefault
expect(bridge.getField('a.b.c')).toBe(schema.shape.a.shape.b.shape.c);
});
+ it('works with default', () => {
+ const schema = object({ a: object({ b: string() }).default({ b: 'x' }) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getField('a')).toBe(schema.shape.a);
+ expect(bridge.getField('a.b')).toBe(
+ schema.shape.a.removeDefault().shape.b,
+ );
+ });
+
it('works with optional', () => {
const schema = object({ a: optional(object({ b: string() })) });
const bridge = new ZodBridge(schema);
const schema = object({ a: array(array(string()).min(1)).min(2) });
const bridge = new ZodBridge(schema);
expect(bridge.getInitialValue('a')).toEqual([[], []]);
+ expect(bridge.getInitialValue('a.0')).toEqual([]);
+ expect(bridge.getInitialValue('a.0.0')).toEqual(undefined);
});
it('works with boolean', () => {
const schema = object({ a: object({ b: object({ c: string() }) }) });
const bridge = new ZodBridge(schema);
expect(bridge.getInitialValue('a')).toEqual({ b: {} });
+ expect(bridge.getInitialValue('a.b')).toEqual({});
+ expect(bridge.getInitialValue('a.b.c')).toEqual(undefined);
+ });
+
+ it('works with default', () => {
+ const schema = object({ a: string().default('x') });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getInitialValue('a')).toEqual('x');
});
it('works with optional', () => {
expect(bridge.getProps('a.b.c')).toEqual({ label: 'C', required: true });
});
+ it('works with default', () => {
+ const schema = object({ a: string().default('x') });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({ label: 'A', required: false });
+ });
+
it('works with optional', () => {
const schema = object({ a: optional(string()) });
const bridge = new ZodBridge(schema);
expect(bridge.getSubfields('a.b.c')).toEqual([]);
});
+ it('works with optional', () => {
+ const schema = object({ a: object({ b: string() }).default({ b: 'x' }) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getSubfields('a')).toEqual(['b']);
+ expect(bridge.getSubfields('a.b')).toEqual([]);
+ });
+
it('works with optional', () => {
const schema = object({ a: optional(object({ b: string() })) });
const bridge = new ZodBridge(schema);
ZodArray,
ZodBoolean,
ZodDate,
+ ZodDefault,
ZodEnum,
ZodError,
ZodNativeEnum,
getField(name: string) {
let field: ZodType = this.schema;
for (const key of joinName(null, name)) {
- if (field instanceof ZodOptional) {
+ if (field instanceof ZodDefault) {
+ field = field.removeDefault();
+ } else if (field instanceof ZodOptional) {
field = field.unwrap();
}
return Array.from({ length }, () => item);
}
+ if (field instanceof ZodDefault) {
+ return field._def.defaultValue();
+ }
+
if (field instanceof ZodEnum) {
return field.options[0];
}
};
let field = this.getField(name);
- if (field instanceof ZodOptional) {
+ if (field instanceof ZodDefault) {
+ field = field.removeDefault();
+ props.required = false;
+ } else if (field instanceof ZodOptional) {
field = field.unwrap();
props.required = false;
}
getSubfields(name = '') {
let field = this.getField(name);
- if (field instanceof ZodOptional) {
+ if (field instanceof ZodDefault) {
+ field = field.removeDefault();
+ } else if (field instanceof ZodOptional) {
field = field.unwrap();
}
getType(name: string) {
let field = this.getField(name);
- if (field instanceof ZodOptional) {
+ if (field instanceof ZodDefault) {
+ field = field.removeDefault();
+ } else if (field instanceof ZodOptional) {
field = field.unwrap();
}
'Address (Zod)': preset`
new ZodBridge(
z.object({
- // TODO: Make it optional.
- city: z.string().max(50),
+ city: z.string().max(50).optional(),
state: z.string(),
street: z.string().max(100),
zip: z.string().regex(/^[0-9]{5}$/),
One last thing left are the details (as usual). For example, the ListAddField
could use a maxCount
prop to know, whether or not it can add more items to the list. Adding it to getProps
is fairly simple:
if field instanceof ZodArray && field._def.maxLength
Of course, there’s a ton of such small things. For now, I handled the maxCount
and minCount
for arrays as well as max
, min
, and step
for numbers. I’m sure we’ll add more within the first few public versions.Complete file-by-file diff.
expect(bridge.getProps('a.0.0')).toEqual({ label: '0', required: true });
});
+ it('works with array (maxCount)', () => {
+ const schema = object({ a: array(array(string())).max(1) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({
+ label: 'A',
+ required: true,
+ maxCount: 1,
+ });
+ });
+
+ it('works with array (minCount)', () => {
+ const schema = object({ a: array(array(string())).min(1) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({
+ label: 'A',
+ required: true,
+ minCount: 1,
+ });
+ });
+
it('works with boolean', () => {
const schema = object({ a: boolean() });
const bridge = new ZodBridge(schema);
it('works with number', () => {
const schema = object({ a: number() });
const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({
+ label: 'A',
+ required: true,
+ decimal: true,
+ });
+ });
+
+ it('works with number (int)', () => {
+ const schema = object({ a: number().int() });
+ const bridge = new ZodBridge(schema);
expect(bridge.getProps('a')).toEqual({ label: 'A', required: true });
});
+ it('works with number (max)', () => {
+ const schema = object({ a: number().max(1) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({
+ label: 'A',
+ required: true,
+ decimal: true,
+ max: 1,
+ });
+ });
+
+ it('works with number (min)', () => {
+ const schema = object({ a: number().min(1) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({
+ label: 'A',
+ required: true,
+ decimal: true,
+ min: 1,
+ });
+ });
+
+ it('works with number (multipleOf)', () => {
+ const schema = object({ a: number().int().multipleOf(7) });
+ const bridge = new ZodBridge(schema);
+ expect(bridge.getProps('a')).toEqual({
+ label: 'A',
+ required: true,
+ step: 7,
+ });
+ });
+
it('works with object', () => {
const schema = object({ a: object({ b: object({ c: string() }) }) });
const bridge = new ZodBridge(schema);
"license": "MIT",
"main": "./cjs/index.js",
"module": "./esm/index.js",
- "sideEffects": false,
+ "sideEffects": [
+ "cjs/index.js",
+ "cjs/register.js",
+ "esm/index.js",
+ "esm/register.js",
+ "src/index.ts",
+ "src/register.ts"
+ ],
"description": "Zod schema bridge for uniforms.",
"repository": "https://github.com/vazco/uniforms/tree/master/packages/uniforms-bridge-zod",
"bugs": "https://github.com/vazco/uniforms/issues",
ZodError,
ZodNativeEnum,
ZodNumber,
+ ZodNumberDef,
ZodObject,
ZodOptional,
ZodRawShape,
props.required = false;
}
- if (field instanceof ZodEnum) {
- props.allowedValues = field.options;
- }
+ if (field instanceof ZodArray) {
+ if (field._def.maxLength) {
+ props.maxCount = field._def.maxLength.value;
+ }
- if (field instanceof ZodNativeEnum) {
+ if (field._def.minLength) {
+ props.minCount = field._def.minLength.value;
+ }
+ } else if (field instanceof ZodEnum) {
+ props.allowedValues = field.options;
+ } else if (field instanceof ZodNativeEnum) {
// Native enums have both numeric and string values.
props.allowedValues = Object.values(field.enum).filter(isNativeEnumValue);
+ } else if (field instanceof ZodNumber) {
+ if (!field.isInt) {
+ props.decimal = true;
+ }
+
+ const max = field.maxValue;
+ if (max !== null) {
+ props.max = max;
+ }
+
+ const min = field.minValue;
+ if (min !== null) {
+ props.min = min;
+ }
+
+ // TODO: File an issue to expose a `.getStep` function.
+ type ZodNumberCheck = ZodNumberDef['checks'][number];
+ const step = field._def.checks.find(
+ (check): check is Extract<ZodNumberCheck, { kind: 'multipleOf' }> =>
+ check.kind === 'multipleOf',
+ );
+ if (step) {
+ props.step = step.value;
+ }
}
return props;
+import './register';
export { default, default as ZodBridge } from './ZodBridge';
+import { filterDOMProps } from 'uniforms';
+
+// There's no possibility to retrieve them at runtime.
+declare module 'uniforms' {
+ interface FilterDOMProps {
+ minCount: never;
+ maxCount: never;
+ }
+}
+
+filterDOMProps.register('minCount', 'maxCount');
Our Zod integration is ready! There are still a few open topics, like how to present error messages or handle asynchronous validation. In any case, this is a thoroughly tested, solid implementation of a uniforms bridge that I’m proud of.
Honestly, after these couple of hours spent with Zod, I can confirm that it’s a great library with decent documentation. And even though I had to check the source a few times, it wasn’t a problem at all.
Personally, I liked this text as well – do expect more in the future!
I love ajv
, but nearly 120kB of code is a lot. Especially, if we’re talking about a simple login form or a landing page. Of course, there are others, but I have mixed experiences with them so far. There’s also the standalone mode, but it requires additional setup and a build step, which is not always feasible.