@radekmie

On uniforms Integration With Zod

By Radosław Miernik · Published on · Comment on Reddit

Table of contents

Intro

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 instanceofs, but it’s nice.

Shall we?

Setup

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.jsons 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:

export default class ZodBridge extends Bridge {}

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.
--- a/package-lock.json
+++ b/package-lock.json
@@ -62,7 +62,8 @@
         "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"
@@ -26494,6 +26495,14 @@
         "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",
@@ -46036,6 +46045,11 @@
       "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",
--- a/package.json
+++ b/package.json
@@ -72,7 +72,8 @@
     "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"
--- /dev/null
+++ b/packages/uniforms-bridge-zod/README.md
@@ -0,0 +1,11 @@
+# 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).
--- /dev/null
+++ b/packages/uniforms-bridge-zod/__tests__/index.ts
@@ -0,0 +1,8 @@
+import * as uniformsZod from 'uniforms-bridge-zod';
+
+it('exports everything', () => {
+  expect(uniformsZod).toEqual({
+    default: expect.any(Function),
+    ZodBridge: expect.any(Function),
+  });
+});
--- /dev/null
+++ b/packages/uniforms-bridge-zod/package.json
@@ -0,0 +1,35 @@
+{
+  "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"
+  }
+}
--- /dev/null
+++ b/packages/uniforms-bridge-zod/src/ZodBridge.ts
@@ -0,0 +1,3 @@
+import { Bridge } from 'uniforms';
+
+export default class ZodBridge extends Bridge {}
--- /dev/null
+++ b/packages/uniforms-bridge-zod/src/index.ts
@@ -0,0 +1 @@
+export { default, default as ZodBridge } from './ZodBridge';
--- /dev/null
+++ b/packages/uniforms-bridge-zod/tsconfig.cjs.json
@@ -0,0 +1,12 @@
+{
+  "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" }]
+}
--- /dev/null
+++ b/packages/uniforms-bridge-zod/tsconfig.esm.json
@@ -0,0 +1,12 @@
+{
+  "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" }]
+}
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -20,6 +20,8 @@
     { "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" },
--- a/tsconfig.global.json
+++ b/tsconfig.global.json
@@ -31,6 +31,8 @@
     { "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" },

List of fields

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:

export default class ZodBridge<T extends ZodRawShape> extends Bridge {
  constructor(public schema: 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 [];
  }
}
Complete file-by-file diff.
--- /dev/null
+++ b/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
@@ -0,0 +1,18 @@
+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']);
+    });
+  });
+});
--- a/packages/uniforms-bridge-zod/src/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/src/ZodBridge.ts
@@ -1,3 +1,17 @@
+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 [];
+  }
+}

Schema navigation

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:

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;
}

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:

getSubfields(name = '') {
  const field = this.getField(name);
  if (field instanceof ZodArray) {
    return ['$'];
  }

  if (field instanceof ZodObject) {
    return Object.keys(field.shape);
   }

  return [];
}
Complete file-by-file diff.
--- a/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
@@ -1,7 +1,38 @@
 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({});
@@ -14,5 +45,28 @@ describe('ZodBridge', () => {
       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([]);
+    });
   });
 });
--- a/packages/uniforms-bridge-zod/src/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/src/ZodBridge.ts
@@ -1,20 +1,45 @@
+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 [];
   }
 }

Field types

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:

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);
}

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.
--- a/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
@@ -1,5 +1,5 @@
 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', () => {
@@ -69,4 +69,42 @@ describe('ZodBridge', () => {
       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);
+    });
+  });
 });
--- a/packages/uniforms-bridge-zod/src/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/src/ZodBridge.ts
@@ -1,7 +1,16 @@
 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);
@@ -42,4 +51,33 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {

     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);
+  }
 }

Validation

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:

getValidator() {
  return (model: Record<string, unknown>) => {
    const result = this.schema.safeParse(model);
    return result.success ? null : result.error;
  };
}
Complete file-by-file diff.
--- a/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
@@ -107,4 +107,12 @@ describe('ZodBridge', () => {
       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));
+    });
+  });
 });
--- a/packages/uniforms-bridge-zod/src/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/src/ZodBridge.ts
@@ -80,4 +80,13 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {

     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;
+    };
+  }
 }

Error handling

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:

getError(name: string, error: unknown) {
  if (!(error instanceof ZodError)) {
    return null;
  }

  return error.issues.find(issue => name === joinName(issue.path)) || null;
}

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.
--- a/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
@@ -2,6 +2,44 @@ import { ZodBridge } from 'uniforms-bridge-zod';
 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({});
--- a/packages/uniforms-bridge-zod/src/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/src/ZodBridge.ts
@@ -5,6 +5,7 @@ import {
   ZodArray,
   ZodBoolean,
   ZodDate,
+  ZodError,
   ZodNumber,
   ZodObject,
   ZodRawShape,
@@ -24,6 +25,14 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {
     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:

getErrorMessage(name: string, error: unknown) {
  return this.getError(name, error)?.message || '';
}
Complete file-by-file diff.
--- a/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
@@ -14,29 +14,73 @@ describe('ZodBridge', () => {
       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);
     });
   });
--- a/packages/uniforms-bridge-zod/src/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/src/ZodBridge.ts
@@ -33,6 +33,10 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {
     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.

getErrorMessages(error: unknown) {
  if (error instanceof ZodError) {
    return error.issues.map(issue => issue.message);
  }

  if (error instanceof Error) {
    return [error.message];
  }

  return [];
}
Complete file-by-file diff.
--- a/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
@@ -84,6 +84,45 @@ describe('ZodBridge', () => {
     });
   });

+  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({});
--- a/packages/uniforms-bridge-zod/src/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/src/ZodBridge.ts
@@ -37,6 +37,20 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {
     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)) {

Initial values

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:

getInitialValue(name: string): 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;
}

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.
--- a/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
@@ -154,6 +154,50 @@ describe('ZodBridge', () => {
     });
   });

+  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({});
--- a/packages/uniforms-bridge-zod/src/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/src/ZodBridge.ts
@@ -66,6 +66,35 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {
     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) {

Field props

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:

getProps(name: string) {
  return {
    label: upperFirst(lowerCase(joinName(null, name).slice(-1)[0])),
    required: true,
  };
}
Complete file-by-file diff.
--- a/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
@@ -198,6 +198,48 @@ describe('ZodBridge', () => {
     });
   });

+  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({});
--- a/packages/uniforms-bridge-zod/src/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/src/ZodBridge.ts
@@ -1,5 +1,7 @@
 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,
@@ -95,6 +97,17 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {
     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) {

Playground integration

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.
--- a/website/lib/presets.ts
+++ b/website/lib/presets.ts
@@ -112,6 +112,18 @@ const presets = {
     )
   `,

+  '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({
@@ -148,6 +160,24 @@ const presets = {
       })
     )
   `,
+
+  '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;
--- a/website/lib/schema.ts
+++ b/website/lib/schema.ts
@@ -6,6 +6,8 @@ import { filterDOMProps } from 'uniforms';
 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';
@@ -32,8 +34,10 @@ scope.GraphQLBridge = GraphQLBridge;
 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: '' } } });
--- a/website/package.json
+++ b/website/package.json
@@ -34,6 +34,7 @@
     "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",

Enums

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 enums 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) {
  return field.options[0];
}

if (field instanceof ZodNativeEnum) {
  return Object.values(field.enum)[0];
}

Next, resolve their type correctly in getType:

if (
  field instanceof ZodEnum ||
  field instanceof ZodNativeEnum ||
  field instanceof ZodString
) {
  return String;
}

// This is no longer needed.
if (field instanceof ZodString) {
  return String;
}

And finally, calculate the allowedValues prop in getProps, to render them using dropdowns and not text inputs:

getProps(name: string) {
  const field = this.getField(name);
  const props: Record<string, unknown> = {
    label: upperFirst(lowerCase(joinName(null, name).slice(-1)[0])),
    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;
}
Complete file-by-file diff.
--- a/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
@@ -1,5 +1,14 @@
 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', () => {
@@ -179,6 +188,48 @@ describe('ZodBridge', () => {
       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);
@@ -219,6 +270,64 @@ describe('ZodBridge', () => {
       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);
@@ -296,6 +405,48 @@ describe('ZodBridge', () => {
       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);
--- a/packages/uniforms-bridge-zod/src/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/src/ZodBridge.ts
@@ -7,7 +7,9 @@ import {
   ZodArray,
   ZodBoolean,
   ZodDate,
+  ZodEnum,
   ZodError,
+  ZodNativeEnum,
   ZodNumber,
   ZodObject,
   ZodRawShape,
@@ -19,6 +21,10 @@ function fieldInvariant(name: string, condition: boolean): asserts condition {
   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();
@@ -83,6 +89,14 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {
       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 => {
@@ -100,12 +114,24 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {
   // 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 = '') {
@@ -135,6 +161,14 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {
       return Date;
     }

+    if (
+      field instanceof ZodEnum ||
+      field instanceof ZodNativeEnum ||
+      field instanceof ZodString
+    ) {
+      return String;
+    }
+
     if (field instanceof ZodNumber) {
       return Number;
     }
@@ -143,10 +177,6 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {
       return Object;
     }

-    if (field instanceof ZodString) {
-      return String;
-    }
-
     invariant(false, 'Field "%s" has an unknown type', name);
   }
--- a/website/lib/presets.ts
+++ b/website/lib/presets.ts
@@ -171,10 +171,9 @@ const presets = {
         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']),
       })
     )
   `,

Optional fields

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) {
  field = field.unwrap();
}

Also, we can finally handle the required prop in getProps correctly:

if (field instanceof ZodOptional) {
  field = field.unwrap();
  props.required = false;
}
Complete file-by-file diff.
--- a/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
@@ -7,6 +7,7 @@ import {
   nativeEnum,
   number,
   object,
+  optional,
   string,
 } from 'zod';

@@ -161,6 +162,13 @@ describe('ZodBridge', () => {
       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', () => {
@@ -242,6 +250,12 @@ describe('ZodBridge', () => {
       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);
@@ -342,6 +356,12 @@ describe('ZodBridge', () => {
       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);
@@ -384,6 +404,13 @@ describe('ZodBridge', () => {
       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', () => {
@@ -459,6 +486,12 @@ describe('ZodBridge', () => {
       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);
--- a/packages/uniforms-bridge-zod/src/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/src/ZodBridge.ts
@@ -12,6 +12,7 @@ import {
   ZodNativeEnum,
   ZodNumber,
   ZodObject,
+  ZodOptional,
   ZodRawShape,
   ZodString,
   ZodType,
@@ -62,6 +63,10 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {
   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;
@@ -115,13 +120,17 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {
   // `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;
     }
@@ -135,7 +144,11 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {
   }

   getSubfields(name = '') {
-    const field = this.getField(name);
+    let field = this.getField(name);
+    if (field instanceof ZodOptional) {
+      field = field.unwrap();
+    }
+
     if (field instanceof ZodArray) {
       return ['$'];
     }
@@ -148,7 +161,11 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {
   }

   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;
     }

Default values

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) {
  return field._def.defaultValue();
}
Complete file-by-file diff.
--- a/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
@@ -163,6 +163,15 @@ describe('ZodBridge', () => {
       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);
@@ -182,6 +191,8 @@ describe('ZodBridge', () => {
       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', () => {
@@ -248,6 +259,14 @@ describe('ZodBridge', () => {
       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', () => {
@@ -356,6 +375,12 @@ describe('ZodBridge', () => {
       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);
@@ -405,6 +430,13 @@ describe('ZodBridge', () => {
       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);
--- a/packages/uniforms-bridge-zod/src/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/src/ZodBridge.ts
@@ -7,6 +7,7 @@ import {
   ZodArray,
   ZodBoolean,
   ZodDate,
+  ZodDefault,
   ZodEnum,
   ZodError,
   ZodNativeEnum,
@@ -63,7 +64,9 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {
   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();
       }

@@ -94,6 +97,10 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {
       return Array.from({ length }, () => item);
     }

+    if (field instanceof ZodDefault) {
+      return field._def.defaultValue();
+    }
+
     if (field instanceof ZodEnum) {
       return field.options[0];
     }
@@ -126,7 +133,10 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {
     };

     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;
     }
@@ -145,7 +155,9 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {

   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();
     }

@@ -162,7 +174,9 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {

   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();
     }
--- a/website/lib/presets.ts
+++ b/website/lib/presets.ts
@@ -115,8 +115,7 @@ const presets = {
   '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}$/),

The details

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) {
  props.maxCount = field._def.maxLength.value;
}

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.
--- a/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/__tests__/ZodBridge.ts
@@ -291,6 +291,26 @@ describe('ZodBridge', () => {
       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);
@@ -364,9 +384,51 @@ describe('ZodBridge', () => {
     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);
--- a/packages/uniforms-bridge-zod/package.json
+++ b/packages/uniforms-bridge-zod/package.json
@@ -4,7 +4,14 @@
   "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",
--- a/packages/uniforms-bridge-zod/src/ZodBridge.ts
+++ b/packages/uniforms-bridge-zod/src/ZodBridge.ts
@@ -12,6 +12,7 @@ import {
   ZodError,
   ZodNativeEnum,
   ZodNumber,
+  ZodNumberDef,
   ZodObject,
   ZodOptional,
   ZodRawShape,
@@ -141,13 +142,43 @@ export default class ZodBridge<T extends ZodRawShape> extends Bridge {
       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;
--- a/packages/uniforms-bridge-zod/src/index.ts
+++ b/packages/uniforms-bridge-zod/src/index.ts
@@ -1 +1,2 @@
+import './register';
 export { default, default as ZodBridge } from './ZodBridge';
--- /dev/null
+++ b/packages/uniforms-bridge-zod/src/register.ts
@@ -0,0 +1,11 @@
+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');

Closing thoughts

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!

1

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.