diff --git a/.changeset/quiet-rockets-juggle.md b/.changeset/quiet-rockets-juggle.md
new file mode 100644
index 0000000..193cb82
--- /dev/null
+++ b/.changeset/quiet-rockets-juggle.md
@@ -0,0 +1,6 @@
+---
+'typesync-cli': minor
+---
+
+Add `generate-zod`, a new generator that emits Zod schemas from Typesync definitions with support for Zod v3 and v4 output.
+Generated schemas include `.describe(...)` metadata, Firestore SDK target handling shared with `generate-ts`, optional `z.infer` type emission, and integration coverage for runtime validation.
diff --git a/docs/cli/generate-ts.mdx b/docs/cli/generate-ts.mdx
index 837a1c0..54a68b2 100644
--- a/docs/cli/generate-ts.mdx
+++ b/docs/cli/generate-ts.mdx
@@ -8,6 +8,7 @@ import DefinitionOption from '/snippets/cli-option-definition.mdx';
import IndentationOption from '/snippets/cli-option-indentation.mdx';
import OutFileOption from '/snippets/cli-option-out-file.mdx';
import TargetOption from '/snippets/cli-option-target.mdx';
+import TSTargetsList from '/snippets/ts-targets-list.mdx';
Generates TypeScript type definitions for the specified schema and writes them to the specified file.
@@ -36,13 +37,4 @@ Controls how objects are defined in the TypeScript output. Object types can be r
## Targets
-- `firebase-admin@13`: For backend projects that use [Firebase Admin Node.js SDK (v13)](https://www.npmjs.com/package/firebase-admin).
-- `firebase-admin@12`: For backend projects that use [Firebase Admin Node.js SDK (v12)](https://www.npmjs.com/package/firebase-admin).
-- `firebase-admin@11`: For backend projects that use [Firebase Admin Node.js SDK (v11)](https://www.npmjs.com/package/firebase-admin).
-- `firebase-admin@10`: For backend projects that use [Firebase Admin Node.js SDK (v10)](https://www.npmjs.com/package/firebase-admin).
-- `firebase@11`: For frontend projects that use [Firebase Javascript SDK (v11)](https://www.npmjs.com/package/firebase).
-- `firebase@10`: For frontend projects that use [Firebase Javascript SDK (v10)](https://www.npmjs.com/package/firebase).
-- `firebase@9`: For frontend projects that use [Firebase Javascript SDK (v9)](https://www.npmjs.com/package/firebase).
-- `react-native-firebase@21`: For React Native projects that use [React Native Firebase (v21)](https://www.npmjs.com/package/@react-native-firebase/firestore).
-- `react-native-firebase@20`: For React Native projects that use [React Native Firebase (v20)](https://www.npmjs.com/package/@react-native-firebase/firestore).
-- `react-native-firebase@19`: For React Native projects that use [React Native Firebase (v19)](https://www.npmjs.com/package/@react-native-firebase/firestore).
+
diff --git a/docs/cli/generate-zod.mdx b/docs/cli/generate-zod.mdx
new file mode 100644
index 0000000..0db7b01
--- /dev/null
+++ b/docs/cli/generate-zod.mdx
@@ -0,0 +1,99 @@
+---
+title: 'generate-zod'
+icon: 'rectangle-terminal'
+---
+
+import DebugOption from '/snippets/cli-option-debug.mdx';
+import DefinitionOption from '/snippets/cli-option-definition.mdx';
+import IndentationOption from '/snippets/cli-option-indentation.mdx';
+import OutFileOption from '/snippets/cli-option-out-file.mdx';
+import TargetOption from '/snippets/cli-option-target.mdx';
+import TSTargetsList from '/snippets/ts-targets-list.mdx';
+
+Generates [Zod](https://zod.dev) schemas for the specified Typesync schema and writes them to the specified file. The
+generated schemas mirror the same mapping rules used internally by `typesync validate-data`, so the structure and
+constraints stay in lockstep across runtime validation and code generation.
+
+Documentation captured in your schema definition is preserved in the output: model-level docs are emitted as JSDoc
+comments and as `.describe(...)` calls on the schema, and field-level docs are emitted as `.describe(...)` calls on the
+field's value schema.
+
+## Usage
+
+```bash
+typesync generate-zod --definition --target --outFile --variant --schemaNamePattern --emitInferredTypes --inferredTypeNamePattern --indentation --debug
+```
+
+## Options
+
+
+
+
+
+
+
+Which Zod major release the generated code should target. Pick the variant that matches the `zod` version installed in
+the consuming project. The two majors differ in a couple of API surface points (the `z.record` key argument and the
+strict / loose object factories) and the generator emits the right shape for each.
+
+
+
+
+
+The pattern that controls how the generated Zod schema constants are named. The pattern must be a string that contains
+the `"{modelName}"` substring (this is a literal value).
+
+Example values:
+
+- `"{modelName}Schema"` → produces exports like `UserSchema`, `PostSchema`, `AccountSchema` etc.
+- `"z{modelName}"` → produces exports like `zUser`, `zPost`, `zAccount` etc.
+
+
+
+
+
+When enabled, the generator emits an inferred TypeScript type alongside each Zod schema. For a model named `User` with
+the default patterns, the output becomes:
+
+```ts
+export const UserSchema = z.strictObject({
+ /* ... */
+});
+export type User = z.infer;
+```
+
+
+
+
+
+The pattern that controls how the inferred TypeScript types are named when `--emitInferredTypes` is set. The pattern
+must contain the literal substring `"{modelName}"`. Ignored when `--emitInferredTypes` is not set.
+
+Example values:
+
+- `"{modelName}"` → produces types like `User`, `Post`, `Account` etc.
+- `"{modelName}Type"` → produces types like `UserType`, `PostType`, `AccountType` etc.
+- `"I{modelName}"` → produces types like `IUser`, `IPost`, `IAccount` etc.
+
+
+
+
+
+
+## Targets
+
+`generate-zod` reuses the `generate-ts` target list. The `--target` option determines the runtime classes that the
+generated `z.instanceof(...)` checks resolve to — pick the target that matches the Firestore SDK you decode documents
+with.
+
+
+
+## Notes
+
+- References between models are emitted as `z.lazy(() => OtherSchema)`. Using `z.lazy` defers identifier resolution until
+ validation time, which keeps mutually recursive schemas working regardless of declaration order in the generated file.
+- Strict / loose objects map to `z.strictObject` / `z.looseObject` for v4 and to `z.object(...).strict()` /
+ `z.object(...).passthrough()` for v3. Whether an object accepts additional fields is controlled by the
+ `additionalFields` flag in the schema definition.
+- The Firestore SDK is only imported when at least one model uses `timestamp` or `bytes`. For the admin targets, `bytes`
+ resolves to the Node global `Buffer` and does not trigger an SDK import on its own.
diff --git a/docs/docs.json b/docs/docs.json
index c429a65..aa04912 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -55,6 +55,7 @@
"cli/generate-ts",
"cli/generate-swift",
"cli/generate-py",
+ "cli/generate-zod",
"cli/generate-rules",
"cli/generate-graph",
"cli/validate-data",
diff --git a/docs/snippets/ts-targets-list.mdx b/docs/snippets/ts-targets-list.mdx
new file mode 100644
index 0000000..bc475ac
--- /dev/null
+++ b/docs/snippets/ts-targets-list.mdx
@@ -0,0 +1,10 @@
+- `firebase-admin@13`: For backend projects that use [Firebase Admin Node.js SDK (v13)](https://www.npmjs.com/package/firebase-admin).
+- `firebase-admin@12`: For backend projects that use [Firebase Admin Node.js SDK (v12)](https://www.npmjs.com/package/firebase-admin).
+- `firebase-admin@11`: For backend projects that use [Firebase Admin Node.js SDK (v11)](https://www.npmjs.com/package/firebase-admin).
+- `firebase-admin@10`: For backend projects that use [Firebase Admin Node.js SDK (v10)](https://www.npmjs.com/package/firebase-admin).
+- `firebase@11`: For frontend projects that use [Firebase Javascript SDK (v11)](https://www.npmjs.com/package/firebase).
+- `firebase@10`: For frontend projects that use [Firebase Javascript SDK (v10)](https://www.npmjs.com/package/firebase).
+- `firebase@9`: For frontend projects that use [Firebase Javascript SDK (v9)](https://www.npmjs.com/package/firebase).
+- `react-native-firebase@21`: For React Native projects that use [React Native Firebase (v21)](https://www.npmjs.com/package/@react-native-firebase/firestore).
+- `react-native-firebase@20`: For React Native projects that use [React Native Firebase (v20)](https://www.npmjs.com/package/@react-native-firebase/firestore).
+- `react-native-firebase@19`: For React Native projects that use [React Native Firebase (v19)](https://www.npmjs.com/package/@react-native-firebase/firestore).
diff --git a/package.json b/package.json
index a7dcae2..a5e695d 100644
--- a/package.json
+++ b/package.json
@@ -55,6 +55,7 @@
"test:integration:python": "tsx scripts/integration-test.ts python",
"test:integration:swift": "tsx scripts/integration-test.ts swift",
"test:integration:typescript": "tsx scripts/integration-test.ts typescript",
+ "test:integration:zod": "tsx scripts/integration-test.ts zod",
"test:rules": "tsx src/cli/index.tsx generate-rules --definition tests/security/definition.yml --outFile tests/security/firestore.rules && firebase emulators:exec --project demo-rules --only firestore 'vitest run --config vitest.security.config.ts'",
"test:src": "vitest run",
"test:watch": "vitest"
@@ -99,6 +100,8 @@
"tsup": "^8.0.2",
"tsx": "^4.7.2",
"typescript": "5.7.2",
- "vitest": "^4.1.5"
+ "vitest": "^4.1.5",
+ "zod-v3": "npm:zod@^3.23.8",
+ "zod-v4": "npm:zod@^4.3.6"
}
}
diff --git a/scripts/integration-test.ts b/scripts/integration-test.ts
index 67f5e56..4fe6809 100644
--- a/scripts/integration-test.ts
+++ b/scripts/integration-test.ts
@@ -18,17 +18,18 @@
* yarn tsx scripts/integration-test.ts all
*/
import { spawnSync } from 'node:child_process';
-import { existsSync, readdirSync, rmSync } from 'node:fs';
+import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { basename, extname, resolve } from 'node:path';
import { argv, exit } from 'node:process';
import { typesync } from '../src/api/index.js';
-type Platform = 'python' | 'swift' | 'typescript';
+type Platform = 'python' | 'swift' | 'typescript' | 'zod';
const REPO_ROOT = resolve(import.meta.dirname, '..');
const FIXTURES_ROOT = resolve(REPO_ROOT, 'tests/integration/_fixtures');
const FIXTURES_SCHEMAS = resolve(FIXTURES_ROOT, 'schemas');
+const ZOD_FIXTURES_SCHEMAS = resolve(REPO_ROOT, 'tests/integration/zod/_fixtures/schemas');
const FIREBASE_PROJECT_ID = 'demo-integration';
// Suites that don't run from the repo root (notably Swift, which runs from
@@ -36,32 +37,73 @@ const FIREBASE_PROJECT_ID = 'demo-integration';
process.env.TYPESYNC_INTEGRATION_FIXTURES_ROOT ??= FIXTURES_ROOT;
interface PlatformConfig {
+ /**
+ * Optional list of additional schema-fixture dirs that this platform
+ * should pull schemas from in addition to the shared
+ * `tests/integration/_fixtures/schemas/`. Used by the Zod suite to pick
+ * up zod-only fixtures (e.g. discriminated unions, simple unions, etc.)
+ * that are not safe to add to the shared dir without forcing every other
+ * platform to add round-trip coverage for them.
+ */
+ extraFixturesSchemasDirs?: string[];
generatedDir: string;
generatedExtension: string;
- generate: (definitionPath: string, outFile: string) => Promise;
/**
- * Optional follow-up generation passes. Used by TypeScript to additionally
- * emit code against alternative SDK targets (e.g. the web SDK) so that
- * cross-target features like the `bytes` primitive can be round-tripped
- * with each target's native representation.
+ * Primary generation pass. Writes to `generatedDir/.`. May
+ * be omitted by platforms whose generations are *all* in subdirs (e.g.
+ * `zod`, which emits `generated/{v3,v4,v4-web}/.ts`).
+ */
+ generate?: (definitionPath: string, outFile: string) => Promise;
+ /**
+ * See `ExtraGeneration`. The TypeScript suite uses this to also emit code
+ * against the web SDK so the `bytes` primitive can be round-tripped with
+ * each target's native representation; the Zod suite uses it to emit each
+ * fixture against both Zod v3 and v4 (and the v4 web SDK).
*/
- extraGenerations?: {
- /**
- * Subdirectory under `generatedDir` that the extra pass writes to. The
- * platform's `tsconfig.json` / `Package.swift` etc. must already include
- * files in this directory.
- */
- subdir: string;
- /**
- * Restricts the pass to a subset of fixtures (matched by basename, e.g.
- * `'secrets'`). Omit to apply to every fixture.
- */
- onlyFixtures?: string[];
- generate: (definitionPath: string, outFile: string) => Promise;
- }[];
+ extraGenerations?: ExtraGeneration[];
+ /**
+ * Optional post-write hook applied to every file written by the *primary*
+ * `generate` pass. Run after the file is written to disk and before any
+ * test runs.
+ */
+ postProcessFile?: (filePath: string) => void;
runs: { description: string; cwd: string; cmd: string; args: string[]; underEmulator: boolean }[];
}
+interface ExtraGeneration {
+ /**
+ * Subdirectory under `generatedDir` that the extra pass writes to. The
+ * platform's `tsconfig.json` / `Package.swift` etc. must already include
+ * files in this directory.
+ */
+ subdir: string;
+ /**
+ * Restricts the pass to a subset of fixtures (matched by basename, e.g.
+ * `'secrets'`). Omit to apply to every fixture.
+ */
+ onlyFixtures?: string[];
+ generate: (definitionPath: string, outFile: string) => Promise;
+ /**
+ * Optional post-write hook. Receives the path of every generated file and
+ * may rewrite its contents in place. Used by the Zod platform to rewrite
+ * `from 'zod'` imports to a versioned alias so v3 and v4 can coexist in a
+ * single Node project.
+ */
+ postProcessFile?: (filePath: string) => void;
+}
+
+/**
+ * Rewrites bare `from 'zod'` and `require('zod')` references to a versioned
+ * alias (e.g. `zod-v3`). Used by the Zod integration suite so we can keep
+ * `zod@3` and `zod@4` installed simultaneously and load each generator's
+ * output against the correct Zod major.
+ */
+function rewriteZodImport(filePath: string, alias: string): void {
+ const original = readFileSync(filePath, 'utf8');
+ const rewritten = original.replaceAll(`from 'zod'`, `from '${alias}'`).replaceAll(`from "zod"`, `from "${alias}"`);
+ if (rewritten !== original) writeFileSync(filePath, rewritten);
+}
+
const PLATFORMS: Record = {
python: {
generatedDir: resolve(REPO_ROOT, 'tests/integration/python/generated'),
@@ -155,20 +197,112 @@ const PLATFORMS: Record = {
},
],
},
+ // Zod integration suite. Each shared + zod-only fixture is emitted three
+ // times: Zod v3 against firebase-admin (primary `generate`), Zod v4 against
+ // firebase-admin (extra `v4`), and Zod v4 against the web SDK (extra
+ // `v4-web`, only for the `bytes` fixture). The primary pass writes to
+ // `generated/v3/.ts`; the post-process step rewrites every
+ // `from 'zod'` to `from 'zod-v3'` so a single Node package can keep both
+ // Zod majors installed side-by-side via npm aliases.
+ zod: {
+ extraFixturesSchemasDirs: [ZOD_FIXTURES_SCHEMAS],
+ generatedDir: resolve(REPO_ROOT, 'tests/integration/zod/generated'),
+ generatedExtension: '.ts',
+ extraGenerations: [
+ {
+ subdir: 'v3',
+ async generate(definition, outFile) {
+ await typesync.generateZod({
+ definition,
+ outFile,
+ target: 'firebase-admin@13',
+ variant: 'v3',
+ emitInferredTypes: true,
+ });
+ },
+ postProcessFile(filePath) {
+ rewriteZodImport(filePath, 'zod-v3');
+ },
+ },
+ {
+ subdir: 'v4',
+ async generate(definition, outFile) {
+ await typesync.generateZod({
+ definition,
+ outFile,
+ target: 'firebase-admin@13',
+ variant: 'v4',
+ emitInferredTypes: true,
+ });
+ },
+ postProcessFile(filePath) {
+ rewriteZodImport(filePath, 'zod-v4');
+ },
+ },
+ {
+ subdir: 'v4-web',
+ onlyFixtures: ['secrets'],
+ async generate(definition, outFile) {
+ await typesync.generateZod({
+ definition,
+ outFile,
+ target: 'firebase@10',
+ variant: 'v4',
+ emitInferredTypes: true,
+ });
+ },
+ postProcessFile(filePath) {
+ rewriteZodImport(filePath, 'zod-v4');
+ },
+ },
+ ],
+ runs: [
+ {
+ description: 'tsc --noEmit (compile-time check)',
+ cwd: resolve(REPO_ROOT, 'tests/integration/zod'),
+ cmd: 'yarn',
+ args: ['tsc', '--noEmit', '-p', '.'],
+ underEmulator: false,
+ },
+ {
+ description: 'vitest (Zod round-trip + Firestore emulator)',
+ cwd: REPO_ROOT,
+ cmd: 'yarn',
+ args: ['vitest', 'run', '-c', 'tests/integration/zod/vitest.config.ts'],
+ underEmulator: true,
+ },
+ ],
+ },
};
-function listSchemaFixtures(): { path: string; name: string }[] {
- if (!existsSync(FIXTURES_SCHEMAS)) {
- throw new Error(`Schema fixtures directory does not exist: ${FIXTURES_SCHEMAS}`);
+function listSchemaFixtures(extraDirs: string[] = []): { path: string; name: string }[] {
+ const dirs = [FIXTURES_SCHEMAS, ...extraDirs];
+ const fixtures: { path: string; name: string }[] = [];
+ for (const dir of dirs) {
+ if (!existsSync(dir)) {
+ throw new Error(`Schema fixtures directory does not exist: ${dir}`);
+ }
+ const entries = readdirSync(dir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml'));
+ for (const file of entries) {
+ fixtures.push({
+ path: resolve(dir, file),
+ name: basename(file, extname(file)),
+ });
+ }
+ }
+ if (fixtures.length === 0) {
+ throw new Error(`No schema fixtures found under ${dirs.join(', ')}`);
}
- const entries = readdirSync(FIXTURES_SCHEMAS).filter(f => f.endsWith('.yml') || f.endsWith('.yaml'));
- if (entries.length === 0) {
- throw new Error(`No schema fixtures found under ${FIXTURES_SCHEMAS}`);
+ // Defensive: a name collision between dirs would silently overwrite
+ // generated files. Easier to surface it loudly than to debug a flaky test.
+ const seen = new Set();
+ for (const fixture of fixtures) {
+ if (seen.has(fixture.name)) {
+ throw new Error(`Duplicate schema fixture name '${fixture.name}' across fixture dirs`);
+ }
+ seen.add(fixture.name);
}
- return entries.map(file => ({
- path: resolve(FIXTURES_SCHEMAS, file),
- name: basename(file, extname(file)),
- }));
+ return fixtures;
}
function clearGeneratedDir(generatedDir: string, extension: string): void {
@@ -188,17 +322,21 @@ function clearGeneratedDir(generatedDir: string, extension: string): void {
async function generateAll(platform: Platform, config: PlatformConfig): Promise {
console.log(`\n[${platform}] generating fixtures…`);
clearGeneratedDir(config.generatedDir, config.generatedExtension);
- const fixtures = listSchemaFixtures();
- for (const fixture of fixtures) {
- const outFile = resolve(config.generatedDir, `${fixture.name}${config.generatedExtension}`);
- await config.generate(fixture.path, outFile);
- console.log(` → ${fixture.name} -> ${outFile}`);
+ const fixtures = listSchemaFixtures(config.extraFixturesSchemasDirs);
+ if (config.generate) {
+ for (const fixture of fixtures) {
+ const outFile = resolve(config.generatedDir, `${fixture.name}${config.generatedExtension}`);
+ await config.generate(fixture.path, outFile);
+ config.postProcessFile?.(outFile);
+ console.log(` → ${fixture.name} -> ${outFile}`);
+ }
}
for (const extra of config.extraGenerations ?? []) {
const targets = extra.onlyFixtures ? fixtures.filter(f => extra.onlyFixtures!.includes(f.name)) : fixtures;
for (const fixture of targets) {
const outFile = resolve(config.generatedDir, extra.subdir, `${fixture.name}${config.generatedExtension}`);
await extra.generate(fixture.path, outFile);
+ extra.postProcessFile?.(outFile);
console.log(` → [${extra.subdir}] ${fixture.name} -> ${outFile}`);
}
}
@@ -243,11 +381,12 @@ async function runPlatform(platform: Platform): Promise {
async function main(): Promise {
const arg = (argv[2] ?? '').toLowerCase();
- const targets: Platform[] = arg === 'all' || arg === '' ? ['python', 'swift', 'typescript'] : [arg as Platform];
+ const targets: Platform[] =
+ arg === 'all' || arg === '' ? ['python', 'swift', 'typescript', 'zod'] : [arg as Platform];
for (const platform of targets) {
if (!(platform in PLATFORMS)) {
- throw new Error(`Unknown platform "${platform}". Expected one of: python, swift, typescript, all.`);
+ throw new Error(`Unknown platform "${platform}". Expected one of: python, swift, typescript, zod, all.`);
}
}
diff --git a/src/api/index.ts b/src/api/index.ts
index 3eeecec..4fd09e6 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -42,6 +42,15 @@ export {
type TSObjectTypeFormat,
getTSTargets,
} from './ts.js';
+export {
+ type GenerateZodOption,
+ type GenerateZodOptions,
+ type GenerateZodRepresentationOptions,
+ type GenerateZodRepresentationResult,
+ type GenerateZodResult,
+ type ZodVariant,
+ getZodVariants,
+} from './zod.js';
export type {
ValidateDataFailure,
ValidateDataModelReport,
diff --git a/src/api/typesync.ts b/src/api/typesync.ts
index ec94272..9eb9b72 100644
--- a/src/api/typesync.ts
+++ b/src/api/typesync.ts
@@ -32,6 +32,12 @@ import type {
GenerateTsResult,
} from './ts.js';
import type { ValidateDataOptions, ValidateDataResult } from './validate-data.js';
+import type {
+ GenerateZodOptions,
+ GenerateZodRepresentationOptions,
+ GenerateZodRepresentationResult,
+ GenerateZodResult,
+} from './zod.js';
export interface GenerateRepresentationOptions {
definition: string;
@@ -101,6 +107,20 @@ export interface Typesync {
*/
generatePyRepresentation(opts: GeneratePythonRepresentationOptions): Promise;
+ /**
+ * Generates Zod schemas for the specified schema and writes them to the specified file.
+ *
+ * @remarks
+ *
+ * This is the programmatic API for the `typesync generate-zod` command.
+ */
+ generateZod(opts: GenerateZodOptions): Promise;
+
+ /**
+ * Generates Zod schemas for the specified schema and returns the generation and the internal representation of the schema without writing anything to the filesystem.
+ */
+ generateZodRepresentation(opts: GenerateZodRepresentationOptions): Promise;
+
/**
* Generates type validator functions for Firestore Security Rules and injects them into the specified file.
*
@@ -160,6 +180,7 @@ export type GenerationResult =
| GenerateTsResult
| GenerateSwiftResult
| GeneratePythonResult
+ | GenerateZodResult
| GenerateRulesResult
| GenerateGraphResult;
diff --git a/src/api/zod.ts b/src/api/zod.ts
new file mode 100644
index 0000000..0f29a3a
--- /dev/null
+++ b/src/api/zod.ts
@@ -0,0 +1,71 @@
+import { ZodGeneration } from '../generators/zod/index.js';
+import { objectKeys } from '../util/object-keys.js';
+import { GenerateRepresentationResult } from './_common.js';
+import type { TSGenerationTarget } from './ts.js';
+
+const ZOD_VARIANTS = {
+ v3: true,
+ v4: true,
+};
+
+/**
+ * Which Zod major release the generated code should target. The two majors
+ * differ in a handful of API surface points (record key argument, strict/loose
+ * object factories) and the generator picks the right shape based on this.
+ */
+export type ZodVariant = keyof typeof ZOD_VARIANTS;
+
+export function getZodVariants() {
+ return objectKeys(ZOD_VARIANTS);
+}
+
+export interface GenerateZodRepresentationOptions {
+ definition: string;
+ /**
+ * Which Firestore SDK the generated Zod schemas validate values against.
+ * Reuses the `generate-ts` target list — the SDK choice only affects the
+ * runtime class checked by `z.instanceof(...)` for the `timestamp` and
+ * `bytes` primitives (e.g. `Buffer` for the Node admin SDK, `firestore.Bytes`
+ * for the web SDK, `firestore.Blob` for `react-native-firebase`).
+ */
+ target: TSGenerationTarget;
+ variant?: ZodVariant;
+ /**
+ * Pattern that controls how the generated Zod schema constants are named.
+ * Must contain the literal substring `{modelName}`. For example, with the
+ * default `'{modelName}Schema'` a model named `User` becomes `UserSchema`.
+ */
+ schemaNamePattern?: string;
+ /**
+ * When `true`, the generator emits an inferred TypeScript type alongside
+ * each Zod schema, e.g. `export type User = z.infer;`.
+ * Defaults to `false`.
+ */
+ emitInferredTypes?: boolean;
+ /**
+ * Pattern that controls how the inferred TypeScript types are named when
+ * `emitInferredTypes` is `true`. Must contain the literal substring
+ * `{modelName}`. For example, with the default `'{modelName}'` a model
+ * named `User` becomes `User`. Ignored when `emitInferredTypes` is `false`.
+ */
+ inferredTypeNamePattern?: string;
+ debug?: boolean;
+}
+
+export interface GenerateZodOptions extends GenerateZodRepresentationOptions {
+ outFile: string;
+ indentation?: number;
+}
+
+export type GenerateZodOption = keyof GenerateZodOptions;
+
+export interface GenerateZodRepresentationResult extends GenerateRepresentationResult {
+ type: 'zod';
+
+ /**
+ * A structured representation of the generated Zod schemas.
+ */
+ generation: ZodGeneration;
+}
+
+export interface GenerateZodResult extends GenerateZodRepresentationResult {}
diff --git a/src/cli/components/GenerationSuccessful.tsx b/src/cli/components/GenerationSuccessful.tsx
index dd667c2..728fb41 100644
--- a/src/cli/components/GenerationSuccessful.tsx
+++ b/src/cli/components/GenerationSuccessful.tsx
@@ -18,6 +18,8 @@ function getMessageForResult(result: GenerationResult) {
return 'Successfully generated Swift type definitions for the specified schema.';
case 'python':
return 'Successfully generated Python/Pydantic type definitions for the specified schema.';
+ case 'zod':
+ return 'Successfully generated Zod schemas for the specified schema.';
case 'rules':
return 'Successfully generated validator functions for Firestore Security Rules.';
case 'graph':
diff --git a/src/cli/index.tsx b/src/cli/index.tsx
index d649ea6..1af2362 100755
--- a/src/cli/index.tsx
+++ b/src/cli/index.tsx
@@ -5,7 +5,14 @@ import React from 'react';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
-import { getPythonTargets, getSchemaGraphOrientations, getSwiftTargets, getTSTargets, typesync } from '../api/index.js';
+import {
+ getPythonTargets,
+ getSchemaGraphOrientations,
+ getSwiftTargets,
+ getTSTargets,
+ getZodVariants,
+ typesync,
+} from '../api/index.js';
import type { ValidateDataProgressEvent } from '../api/index.js';
import { getObjectTypeFormats } from '../api/ts.js';
import {
@@ -36,8 +43,16 @@ import {
DEFAULT_VALIDATE_DATA_JSON,
DEFAULT_VALIDATE_DATA_MAX_RETRIES,
DEFAULT_VALIDATE_DEBUG,
+ DEFAULT_ZOD_DEBUG,
+ DEFAULT_ZOD_EMIT_INFERRED_TYPES,
+ DEFAULT_ZOD_INDENTATION,
+ DEFAULT_ZOD_INFERRED_TYPE_NAME_PATTERN,
+ DEFAULT_ZOD_SCHEMA_NAME_PATTERN,
+ DEFAULT_ZOD_VARIANT,
RULES_READONLY_FIELD_VALIDATOR_NAME_PATTERN_PARAM,
RULES_TYPE_VALIDATOR_NAME_PATTERN_PARAM,
+ ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM,
+ ZOD_SCHEMA_NAME_PATTERN_PARAM,
} from '../constants.js';
import { extractErrorMessage } from '../util/extract-error-message.js';
import { extractPackageJsonVersion } from '../util/extract-package-json-version.js';
@@ -245,6 +260,103 @@ await yargs(hideBin(process.argv))
}
}
)
+ .command(
+ 'generate-zod',
+ 'Generates Zod schemas for the specified schema and writes them to the specified file.',
+ y =>
+ y
+ .option('definition', {
+ describe:
+ 'The exact path or a Glob pattern to the schema definition file or files. Each definition file must be a YAML file containing model definitions.',
+ type: 'string',
+ demandOption: true,
+ })
+ .option('target', {
+ describe:
+ 'The target Firestore SDK that the generated Zod schemas will validate data against. This determines the runtime classes used for `timestamp` and `bytes` (e.g. `Buffer` for the Node admin SDK, `firestore.Bytes` for the web SDK). Shares the `generate-ts` target list.',
+ type: 'string',
+ demandOption: true,
+ choices: getTSTargets(),
+ })
+ .option('outFile', {
+ describe: 'The path to the output file.',
+ type: 'string',
+ demandOption: true,
+ })
+ .option('variant', {
+ describe:
+ 'Which Zod major release the generated code should target. Pick the variant that matches the `zod` version installed in the consuming project.',
+ type: 'string',
+ demandOption: false,
+ choices: getZodVariants(),
+ default: DEFAULT_ZOD_VARIANT,
+ })
+ .option('schemaNamePattern', {
+ describe: `The pattern that controls how the generated Zod schema constants are named. The pattern must contain the literal substring '${ZOD_SCHEMA_NAME_PATTERN_PARAM}'. For example, providing '${ZOD_SCHEMA_NAME_PATTERN_PARAM}Schema' (the default) ensures that schemas are exported as 'UserSchema', 'PostSchema', etc.`,
+ type: 'string',
+ demandOption: false,
+ default: DEFAULT_ZOD_SCHEMA_NAME_PATTERN,
+ })
+ .option('emitInferredTypes', {
+ describe:
+ 'When enabled, the generator emits an inferred TypeScript type alongside each Zod schema (e.g. `export type User = z.infer;`). Disabled by default to avoid colliding with type names produced by `generate-ts`.',
+ type: 'boolean',
+ demandOption: false,
+ default: DEFAULT_ZOD_EMIT_INFERRED_TYPES,
+ })
+ .option('inferredTypeNamePattern', {
+ describe: `The pattern that controls how the inferred TypeScript types are named when '--emitInferredTypes' is set. The pattern must contain the literal substring '${ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM}'. For example, providing '${ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM}' (the default) emits 'User', 'Post', etc.; providing '${ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM}Type' emits 'UserType', 'PostType', etc.`,
+ type: 'string',
+ demandOption: false,
+ default: DEFAULT_ZOD_INFERRED_TYPE_NAME_PATTERN,
+ })
+ .option('indentation', {
+ describe: 'Indentation or tab width for the generated code.',
+ type: 'number',
+ demandOption: false,
+ default: DEFAULT_ZOD_INDENTATION,
+ })
+ .option('debug', {
+ describe: 'Whether to enable debug logs.',
+ type: 'boolean',
+ demandOption: false,
+ default: DEFAULT_ZOD_DEBUG,
+ }),
+ async args => {
+ const {
+ definition,
+ target,
+ outFile,
+ variant,
+ schemaNamePattern,
+ emitInferredTypes,
+ inferredTypeNamePattern,
+ indentation,
+ debug,
+ } = args;
+
+ const pathToOutputFile = resolve(process.cwd(), outFile);
+ try {
+ const result = await typesync.generateZod({
+ definition: resolve(process.cwd(), definition),
+ target,
+ outFile: pathToOutputFile,
+ variant,
+ schemaNamePattern,
+ emitInferredTypes,
+ inferredTypeNamePattern,
+ indentation,
+ debug,
+ });
+
+ render();
+ } catch (e) {
+ const message = extractErrorMessage(e);
+ render();
+ yargs().exit(1, new Error(message));
+ }
+ }
+ )
.command(
'generate-rules',
'Generates type validator functions for Firestore Security Rules and injects them into the specified file.',
diff --git a/src/constants.ts b/src/constants.ts
index c3ee020..228a610 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -1,9 +1,12 @@
import type { SchemaGraphOrientation } from './api/graph.js';
import type { TSObjectTypeFormat } from './api/ts.js';
+import type { ZodVariant } from './api/zod.js';
export const PYTHON_UNDEFINED_SENTINEL_CLASS = 'TypesyncUndefined';
export const RULES_TYPE_VALIDATOR_NAME_PATTERN_PARAM = '{modelName}';
export const RULES_READONLY_FIELD_VALIDATOR_NAME_PATTERN_PARAM = '{modelName}';
+export const ZOD_SCHEMA_NAME_PATTERN_PARAM = '{modelName}';
+export const ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM = '{modelName}';
/*
* Default values
@@ -15,6 +18,13 @@ export const DEFAULT_TS_DEBUG = false;
export const DEFAULT_SWIFT_INDENTATION = 2;
export const DEFAULT_SWIFT_DEBUG = false;
+export const DEFAULT_ZOD_VARIANT: ZodVariant = 'v4';
+export const DEFAULT_ZOD_SCHEMA_NAME_PATTERN = `${ZOD_SCHEMA_NAME_PATTERN_PARAM}Schema`;
+export const DEFAULT_ZOD_EMIT_INFERRED_TYPES = false;
+export const DEFAULT_ZOD_INFERRED_TYPE_NAME_PATTERN = ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM;
+export const DEFAULT_ZOD_INDENTATION = 2;
+export const DEFAULT_ZOD_DEBUG = false;
+
export const DEFAULT_PY_CUSTOM_PYDANTIC_BASE = undefined;
export const DEFAULT_PY_UNDEFINED_SENTINEL_NAME = 'UNDEFINED';
export const DEFAULT_PY_INDENTATION = 2;
diff --git a/src/core/typesync.ts b/src/core/typesync.ts
index c26ea63..7894c9d 100644
--- a/src/core/typesync.ts
+++ b/src/core/typesync.ts
@@ -21,6 +21,10 @@ import type {
GenerateTsRepresentationOptions,
GenerateTsRepresentationResult,
GenerateTsResult,
+ GenerateZodOptions,
+ GenerateZodRepresentationOptions,
+ GenerateZodRepresentationResult,
+ GenerateZodResult,
PythonGenerationTarget,
SchemaGraphOrientation,
SwiftGenerationTarget,
@@ -31,6 +35,7 @@ import type {
ValidateDataResult,
ValidateOptions,
ValidateResult,
+ ZodVariant,
} from '../api/index.js';
import { GenerateRepresentationOptions, GenerateRepresentationResult } from '../api/typesync.js';
import {
@@ -56,8 +61,16 @@ import {
DEFAULT_TS_DEBUG,
DEFAULT_TS_INDENTATION,
DEFAULT_VALIDATE_DEBUG,
+ DEFAULT_ZOD_DEBUG,
+ DEFAULT_ZOD_EMIT_INFERRED_TYPES,
+ DEFAULT_ZOD_INDENTATION,
+ DEFAULT_ZOD_INFERRED_TYPE_NAME_PATTERN,
+ DEFAULT_ZOD_SCHEMA_NAME_PATTERN,
+ DEFAULT_ZOD_VARIANT,
RULES_READONLY_FIELD_VALIDATOR_NAME_PATTERN_PARAM,
RULES_TYPE_VALIDATOR_NAME_PATTERN_PARAM,
+ ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM,
+ ZOD_SCHEMA_NAME_PATTERN_PARAM,
} from '../constants.js';
import { DefinitionFilesNotFoundError } from '../errors/invalid-def.js';
import {
@@ -77,6 +90,9 @@ import {
InvalidTypeValidatorNamePatternOptionError,
InvalidTypeValidatorParamNameOptionError,
InvalidUndefinedSentinelNameOptionError,
+ InvalidZodIndentationOptionError,
+ InvalidZodInferredTypeNamePatternOptionError,
+ InvalidZodSchemaNamePatternOptionError,
RulesMarkerOptionsNotDistinctError,
ValidatorNamePatternsNotDistinctError,
} from '../errors/invalid-opts.js';
@@ -85,6 +101,7 @@ import { createPythonGenerator } from '../generators/python/index.js';
import { createRulesGenerator } from '../generators/rules/index.js';
import { createSwiftGenerator } from '../generators/swift/index.js';
import { createTSGenerator } from '../generators/ts/index.js';
+import { createZodGenerator } from '../generators/zod/index.js';
import { renderers } from '../renderers/index.js';
import { createSchemaGraphFromSchema } from '../schema-graph/create-from-schema.js';
import { schema } from '../schema/index.js';
@@ -118,6 +135,21 @@ interface NormalizedGenerateSwiftOptions extends NormalizedGenerateSwiftRepresen
indentation: number;
}
+interface NormalizedGenerateZodRepresentationOptions {
+ definitionGlobPattern: string;
+ target: TSGenerationTarget;
+ variant: ZodVariant;
+ schemaNamePattern: string;
+ emitInferredTypes: boolean;
+ inferredTypeNamePattern: string;
+ debug: boolean;
+}
+
+interface NormalizedGenerateZodOptions extends NormalizedGenerateZodRepresentationOptions {
+ pathToOutputFile: string;
+ indentation: number;
+}
+
interface NormalizedGeneratePythonRepresentationOptions {
definitionGlobPattern: string;
target: PythonGenerationTarget;
@@ -247,6 +279,88 @@ class TypesyncImpl implements Typesync {
};
}
+ public async generateZod(rawOpts: GenerateZodOptions): Promise {
+ const opts = this.normalizeGenerateZodOpts(rawOpts);
+ const { schema: s, generation } = await this.generateZodRepresentation(rawOpts);
+ const renderer = renderers.createZodRenderer({
+ target: opts.target,
+ variant: opts.variant,
+ indentation: opts.indentation,
+ });
+ const file = await renderer.render(generation);
+ await writeFile(opts.pathToOutputFile, file.content);
+ return { type: 'zod', schema: s, generation };
+ }
+
+ public async generateZodRepresentation(
+ rawOpts: GenerateZodRepresentationOptions
+ ): Promise {
+ const opts = this.normalizeGenerateZodRepresentationOpts(rawOpts);
+ const {
+ definitionGlobPattern,
+ target,
+ variant,
+ schemaNamePattern,
+ emitInferredTypes,
+ inferredTypeNamePattern,
+ debug,
+ } = opts;
+ const { schema: s } = this.createCoreObjects(definitionGlobPattern, debug);
+ const generator = createZodGenerator({
+ target,
+ variant,
+ schemaNamePattern,
+ emitInferredTypes,
+ inferredTypeNamePattern,
+ });
+ const generation = generator.generate(s);
+ return { type: 'zod', schema: s, generation };
+ }
+
+ private normalizeGenerateZodOpts(opts: GenerateZodOptions): NormalizedGenerateZodOptions {
+ const { outFile, indentation = DEFAULT_ZOD_INDENTATION, ...rest } = opts;
+ if (!Number.isSafeInteger(indentation) || indentation < 1) {
+ throw new InvalidZodIndentationOptionError(indentation);
+ }
+ return {
+ ...this.normalizeGenerateZodRepresentationOpts(rest),
+ pathToOutputFile: outFile,
+ indentation,
+ };
+ }
+
+ private normalizeGenerateZodRepresentationOpts(
+ opts: GenerateZodRepresentationOptions
+ ): NormalizedGenerateZodRepresentationOptions {
+ const {
+ definition,
+ target,
+ variant = DEFAULT_ZOD_VARIANT,
+ schemaNamePattern = DEFAULT_ZOD_SCHEMA_NAME_PATTERN,
+ emitInferredTypes = DEFAULT_ZOD_EMIT_INFERRED_TYPES,
+ inferredTypeNamePattern = DEFAULT_ZOD_INFERRED_TYPE_NAME_PATTERN,
+ debug = DEFAULT_ZOD_DEBUG,
+ } = opts;
+
+ if (!schemaNamePattern.includes(ZOD_SCHEMA_NAME_PATTERN_PARAM)) {
+ throw new InvalidZodSchemaNamePatternOptionError(schemaNamePattern);
+ }
+
+ if (!inferredTypeNamePattern.includes(ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM)) {
+ throw new InvalidZodInferredTypeNamePatternOptionError(inferredTypeNamePattern);
+ }
+
+ return {
+ definitionGlobPattern: definition,
+ target,
+ variant,
+ schemaNamePattern,
+ emitInferredTypes,
+ inferredTypeNamePattern,
+ debug,
+ };
+ }
+
public async generatePy(rawOpts: GeneratePythonOptions): Promise {
const opts = this.normalizeGeneratePyOpts(rawOpts);
const { schema: s, generation } = await this.generatePyRepresentation(rawOpts);
diff --git a/src/core/zod/__tests__/codegen-emitter.test.ts b/src/core/zod/__tests__/codegen-emitter.test.ts
new file mode 100644
index 0000000..ad968d5
--- /dev/null
+++ b/src/core/zod/__tests__/codegen-emitter.test.ts
@@ -0,0 +1,189 @@
+import { schema } from '../../../schema/index.js';
+import { createCodegenZodEmitter } from '../_codegen-emitter.js';
+import { buildZodFromType } from '../build-zod-schema.js';
+
+function emit(
+ type: schema.types.Type,
+ overrides: { variant?: 'v3' | 'v4'; target?: 'firebase-admin@13' | 'firebase@10' | 'react-native-firebase@21' } = {}
+) {
+ const emitter = createCodegenZodEmitter({
+ variant: overrides.variant ?? 'v4',
+ target: overrides.target ?? 'firebase-admin@13',
+ getSchemaIdentifierForModel: name => `${name}Schema`,
+ });
+ return buildZodFromType(type, emitter);
+}
+
+describe('createCodegenZodEmitter()', () => {
+ describe('primitives, literals, and enums', () => {
+ it('emits the canonical zod constructor for each primitive type', () => {
+ expect(emit({ type: 'any' })).toBe('z.any()');
+ expect(emit({ type: 'unknown' })).toBe('z.unknown()');
+ expect(emit({ type: 'nil' })).toBe('z.null()');
+ expect(emit({ type: 'string' })).toBe('z.string()');
+ expect(emit({ type: 'boolean' })).toBe('z.boolean()');
+ expect(emit({ type: 'int' })).toBe('z.number().int()');
+ expect(emit({ type: 'double' })).toBe('z.number()');
+ });
+
+ it('emits literals with JSON-encoded values so strings keep their quotes', () => {
+ expect(emit({ type: 'string-literal', value: "it's" })).toBe(`z.literal("it's")`);
+ expect(emit({ type: 'int-literal', value: 42 })).toBe('z.literal(42)');
+ expect(emit({ type: 'boolean-literal', value: true })).toBe('z.literal(true)');
+ });
+
+ it('emits string enums as z.enum([...]) regardless of member count', () => {
+ const single = emit({ type: 'string-enum', members: [{ label: 'A', value: 'a' }] });
+ expect(single).toBe(`z.enum(["a"])`);
+
+ const multi = emit({
+ type: 'string-enum',
+ members: [
+ { label: 'Red', value: 'red' },
+ { label: 'Blue', value: 'blue' },
+ ],
+ });
+ expect(multi).toBe(`z.enum(["red", "blue"])`);
+ });
+
+ it('emits int enums as a literal union (z.enum is string-only in Zod)', () => {
+ const single = emit({ type: 'int-enum', members: [{ label: 'One', value: 1 }] });
+ expect(single).toBe('z.union([z.literal(1)])');
+
+ const multi = emit({
+ type: 'int-enum',
+ members: [
+ { label: 'Lo', value: 1 },
+ { label: 'Hi', value: 2 },
+ ],
+ });
+ expect(multi).toBe('z.union([z.literal(1), z.literal(2)])');
+ });
+ });
+
+ describe('Firestore-bound primitives', () => {
+ it('uses Buffer for bytes on the admin target and casts firestore.Bytes/Blob (private constructors) for the others', () => {
+ expect(emit({ type: 'bytes' }, { target: 'firebase-admin@13' })).toBe('z.instanceof(Buffer)');
+ // The web SDK's `firestore.Bytes` declares a private constructor, which
+ // does not satisfy `z.instanceof`'s `new (...args: any[]) => any`
+ // constraint. The codegen emits a constructor-shape cast so the
+ // generated source type-checks under strict TypeScript.
+ expect(emit({ type: 'bytes' }, { target: 'firebase@10' })).toBe(
+ 'z.instanceof(firestore.Bytes as unknown as new (...args: never[]) => firestore.Bytes)'
+ );
+ expect(emit({ type: 'bytes' }, { target: 'react-native-firebase@21' })).toBe(
+ 'z.instanceof(firestore.Blob as unknown as new (...args: never[]) => firestore.Blob)'
+ );
+ });
+
+ it('uses firestore.Timestamp regardless of target so the runtime check matches the SDK class', () => {
+ expect(emit({ type: 'timestamp' }, { target: 'firebase-admin@13' })).toBe('z.instanceof(firestore.Timestamp)');
+ expect(emit({ type: 'timestamp' }, { target: 'firebase@10' })).toBe('z.instanceof(firestore.Timestamp)');
+ });
+ });
+
+ describe('records, arrays, and tuples', () => {
+ it('emits z.record without a key schema for v3 and with z.string() for v4', () => {
+ const type: schema.types.Map = { type: 'map', valueType: { type: 'string' } };
+ expect(emit(type, { variant: 'v3' })).toBe('z.record(z.string())');
+ expect(emit(type, { variant: 'v4' })).toBe('z.record(z.string(), z.string())');
+ });
+
+ it('emits z.array(value) and z.tuple([...]) consistently across variants', () => {
+ expect(emit({ type: 'list', elementType: { type: 'string' } })).toBe('z.array(z.string())');
+ expect(emit({ type: 'tuple', elements: [{ type: 'string' }, { type: 'int' }] })).toBe(
+ 'z.tuple([z.string(), z.number().int()])'
+ );
+ expect(emit({ type: 'tuple', elements: [] })).toBe('z.tuple([])');
+ });
+ });
+
+ describe('object types', () => {
+ function makeField(
+ overrides: Partial & { name: string; type: schema.types.Type }
+ ): schema.types.ObjectField {
+ return {
+ optional: false,
+ readonly: false,
+ docs: null,
+ ...overrides,
+ };
+ }
+
+ const objectType: schema.types.Object = {
+ type: 'object',
+ additionalFields: false,
+ fields: [
+ makeField({ name: 'id', type: { type: 'string' }, docs: 'The id' }),
+ makeField({ name: 'age', type: { type: 'int' }, optional: true }),
+ ],
+ };
+
+ it('uses z.object().strict()/.passthrough() for v3 and z.strictObject/z.looseObject for v4', () => {
+ const v3Strict = emit(objectType, { variant: 'v3' });
+ expect(v3Strict).toContain(`.strict()`);
+ expect(v3Strict.startsWith('z.object(')).toBe(true);
+
+ const v3Loose = emit({ ...objectType, additionalFields: true }, { variant: 'v3' });
+ expect(v3Loose).toContain(`.passthrough()`);
+
+ const v4Strict = emit(objectType, { variant: 'v4' });
+ expect(v4Strict.startsWith('z.strictObject(')).toBe(true);
+
+ const v4Loose = emit({ ...objectType, additionalFields: true }, { variant: 'v4' });
+ expect(v4Loose.startsWith('z.looseObject(')).toBe(true);
+ });
+
+ it('attaches .describe(...) to the inner value for fields with docs and wraps optional fields with .optional()', () => {
+ const out = emit(objectType, { variant: 'v4' });
+ expect(out).toContain(`id: z.string().describe("The id")`);
+ expect(out).toContain(`age: z.number().int().optional()`);
+ });
+
+ it('quotes property names that are not valid JS identifiers', () => {
+ const trickyObject: schema.types.Object = {
+ type: 'object',
+ additionalFields: false,
+ fields: [
+ makeField({ name: 'kebab-key', type: { type: 'string' } }),
+ makeField({ name: '1startsWithDigit', type: { type: 'string' } }),
+ ],
+ };
+ const out = emit(trickyObject);
+ expect(out).toContain(`"kebab-key":`);
+ expect(out).toContain(`"1startsWithDigit":`);
+ });
+ });
+
+ describe('unions', () => {
+ it('emits z.union([...]) for simple unions and unwraps single-variant unions', () => {
+ const single = emit({ type: 'simple-union', variants: [{ type: 'string' }] });
+ expect(single).toBe('z.string()');
+
+ const multi = emit({
+ type: 'simple-union',
+ variants: [{ type: 'string' }, { type: 'int' }],
+ });
+ expect(multi).toBe('z.union([z.string(), z.number().int()])');
+ });
+
+ it('emits z.discriminatedUnion(, [...]) for discriminated unions', () => {
+ const out = emit({
+ type: 'discriminated-union',
+ discriminant: 'kind',
+ variants: [
+ { type: 'alias', name: 'Cat' },
+ { type: 'alias', name: 'Dog' },
+ ],
+ });
+ expect(out).toBe(`z.discriminatedUnion("kind", [z.lazy(() => CatSchema), z.lazy(() => DogSchema)])`);
+ });
+ });
+
+ describe('alias references', () => {
+ it('emits z.lazy(() => Identifier) using the configured identifier mapper', () => {
+ const out = emit({ type: 'alias', name: 'User' });
+ expect(out).toBe('z.lazy(() => UserSchema)');
+ });
+ });
+});
diff --git a/src/core/zod/_codegen-emitter.ts b/src/core/zod/_codegen-emitter.ts
new file mode 100644
index 0000000..b1febe2
--- /dev/null
+++ b/src/core/zod/_codegen-emitter.ts
@@ -0,0 +1,180 @@
+import type { TSGenerationTarget } from '../../api/ts.js';
+import { assertNever } from '../../util/assert.js';
+import type { ZodEmitter } from './_emitter.js';
+
+/**
+ * Which Zod major API the emitter should target. The two majors differ in a
+ * handful of places that matter to codegen:
+ *
+ * - records: v3 takes a single argument `z.record(value)`; v4 requires the key
+ * schema as the first argument: `z.record(z.string(), value)`.
+ * - object passthrough/strict: v3 expresses these as chainable methods on
+ * `z.object(...)` (`.strict()`, `.passthrough()`); v4 ships dedicated
+ * factories (`z.strictObject(...)`, `z.looseObject(...)`).
+ */
+export type ZodVariant = 'v3' | 'v4';
+
+export interface ZodCodegenEmitterConfig {
+ variant: ZodVariant;
+ /**
+ * Identifies the Firestore SDK that the generated Zod schemas will be
+ * validating data against. Shares the `generate-ts` target list — the SDK
+ * identity only matters for picking the right runtime class for the
+ * `timestamp` and `bytes` primitives (e.g. `Buffer` vs `firestore.Bytes`
+ * vs `firestore.Blob`).
+ */
+ target: TSGenerationTarget;
+ /**
+ * Maps a Typesync model name (e.g. `User`) to the identifier under which the
+ * corresponding Zod schema will be exported in the generated file
+ * (e.g. `UserSchema`). The codegen emitter uses this to wire references
+ * between schemas.
+ */
+ getSchemaIdentifierForModel: (modelName: string) => string;
+}
+
+/**
+ * Emitter that produces Zod **source code** (as plain strings) instead of live
+ * `ZodType` instances. Used by the `generate-zod` command. The runtime emitter
+ * (`./_runtime-emitter.ts`) and this codegen emitter are driven by the same
+ * `buildZodFromType` traversal so the schema-to-Zod mapping rules live in
+ * exactly one place.
+ */
+export function createCodegenZodEmitter(config: ZodCodegenEmitterConfig): ZodEmitter {
+ const { variant, target, getSchemaIdentifierForModel } = config;
+
+ const timestampExpression = expressionForTimestampInstanceCheck(target);
+ const bytesExpression = expressionForBytesInstanceCheck(target);
+
+ return {
+ any: () => 'z.any()',
+ unknown: () => 'z.unknown()',
+ nullType: () => 'z.null()',
+ string: () => 'z.string()',
+ boolean: () => 'z.boolean()',
+ int: () => 'z.number().int()',
+ double: () => 'z.number()',
+ timestamp: () => `z.instanceof(${timestampExpression})`,
+ bytes: () => `z.instanceof(${bytesExpression})`,
+
+ stringLiteral: value => `z.literal(${JSON.stringify(value)})`,
+ intLiteral: value => `z.literal(${value})`,
+ booleanLiteral: value => `z.literal(${value})`,
+
+ // String enums use the canonical `z.enum([...])` form. It works in Zod
+ // v3 and v4 alike (including with a single member) and gives much better
+ // error messages than a literal union. Int enums fall back to a literal
+ // union because `z.enum([...])` is string-only in Zod and the v4-only
+ // `z.literal([1, 2])` form does not accept single-member arrays.
+ stringEnum: values => `z.enum([${values.map(v => JSON.stringify(v)).join(', ')}])`,
+ intEnum: values => `z.union([${values.map(v => `z.literal(${v})`).join(', ')}])`,
+
+ tuple: elements => `z.tuple([${elements.join(', ')}])`,
+ array: element => `z.array(${element})`,
+ record: value => (variant === 'v3' ? `z.record(${value})` : `z.record(z.string(), ${value})`),
+
+ object: (fields, additionalFields) => {
+ const propertyEntries = fields.map(field => {
+ let valueExpression = field.value;
+ if (field.docs !== null && field.docs.length > 0) {
+ valueExpression = `${valueExpression}.describe(${JSON.stringify(field.docs)})`;
+ }
+ if (field.optional) {
+ valueExpression = `${valueExpression}.optional()`;
+ }
+ return `${propertyKey(field.name)}: ${valueExpression}`;
+ });
+ const objectLiteral = propertyEntries.length > 0 ? `{ ${propertyEntries.join(', ')} }` : '{}';
+ return objectFactoryExpression(variant, additionalFields, objectLiteral);
+ },
+
+ // Mirrors the runtime emitter: a degenerate 0/1-variant union collapses to
+ // its sole variant (or `z.never()` if empty) rather than emitting a literal
+ // `z.union([single])`. Reasons: (1) it keeps both emitters semantically
+ // equivalent; (2) it sidesteps Zod v3's `[A, A, ...A[]]` tuple type for
+ // `z.union`, which would otherwise require an `as`-cast in the generated
+ // file; (3) the output is cleaner.
+ simpleUnion: unionVariants => {
+ if (unionVariants.length < 2) return unionVariants[0] ?? 'z.never()';
+ return `z.union([${unionVariants.join(', ')}])`;
+ },
+
+ discriminatedUnion: (discriminant, unionVariants) => {
+ if (unionVariants.length < 2) return unionVariants[0] ?? 'z.never()';
+ return `z.discriminatedUnion(${JSON.stringify(discriminant)}, [${unionVariants.join(', ')}])`;
+ },
+
+ reference: modelName => {
+ const identifier = getSchemaIdentifierForModel(modelName);
+ // `z.lazy` defers identifier access to validation time, which is what
+ // makes mutually recursive schemas in the generated file work regardless
+ // of declaration order.
+ return `z.lazy(() => ${identifier})`;
+ },
+ };
+}
+
+function objectFactoryExpression(variant: ZodVariant, additionalFields: boolean, objectLiteral: string): string {
+ switch (variant) {
+ case 'v3':
+ return additionalFields ? `z.object(${objectLiteral}).passthrough()` : `z.object(${objectLiteral}).strict()`;
+ case 'v4':
+ return additionalFields ? `z.looseObject(${objectLiteral})` : `z.strictObject(${objectLiteral})`;
+ default:
+ assertNever(variant);
+ }
+}
+
+const VALID_IDENTIFIER_REGEX = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
+
+function propertyKey(name: string): string {
+ return VALID_IDENTIFIER_REGEX.test(name) ? name : JSON.stringify(name);
+}
+
+function expressionForTimestampInstanceCheck(target: TSGenerationTarget): string {
+ switch (target) {
+ case 'firebase-admin@13':
+ case 'firebase-admin@12':
+ case 'firebase-admin@11':
+ case 'firebase-admin@10':
+ case 'firebase@11':
+ case 'firebase@10':
+ case 'firebase@9':
+ case 'react-native-firebase@21':
+ case 'react-native-firebase@20':
+ case 'react-native-firebase@19':
+ return 'firestore.Timestamp';
+ default:
+ assertNever(target);
+ }
+}
+
+function expressionForBytesInstanceCheck(target: TSGenerationTarget): string {
+ switch (target) {
+ case 'firebase-admin@13':
+ case 'firebase-admin@12':
+ case 'firebase-admin@11':
+ case 'firebase-admin@10':
+ // Firestore bytes are represented as Node `Buffer` in admin. Buffer
+ // has a public constructor so it satisfies `z.instanceof` directly.
+ return 'Buffer';
+ case 'firebase@11':
+ case 'firebase@10':
+ case 'firebase@9':
+ // `firestore.Bytes` declares a private constructor, which violates the
+ // `new (...args: any[]) => any` constraint enforced by `z.instanceof`.
+ // The cast has to go through `unknown` because TypeScript otherwise
+ // refuses the conversion ("private vs public constructor"). The cast
+ // is erased at runtime; the actual instance check still runs against
+ // the real class object.
+ return 'firestore.Bytes as unknown as new (...args: never[]) => firestore.Bytes';
+ case 'react-native-firebase@21':
+ case 'react-native-firebase@20':
+ case 'react-native-firebase@19':
+ // Same reason as `firestore.Bytes` above — `firestore.Blob` has a
+ // private constructor in the `@react-native-firebase/firestore` types.
+ return 'firestore.Blob as unknown as new (...args: never[]) => firestore.Blob';
+ default:
+ assertNever(target);
+ }
+}
diff --git a/src/core/zod/_emitter.ts b/src/core/zod/_emitter.ts
index 4fd6444..f51c273 100644
--- a/src/core/zod/_emitter.ts
+++ b/src/core/zod/_emitter.ts
@@ -41,6 +41,13 @@ export interface ZodEmitter {
name: string;
value: TOut;
optional: boolean;
+ /**
+ * Documentation attached to the field in the source schema, if any.
+ * The runtime emitter ignores this; the codegen emitter folds it into
+ * a `.describe(...)` call so the generated Zod schema preserves the
+ * field-level docs from the original definition.
+ */
+ docs: string | null;
}>,
additionalFields: boolean
): TOut;
diff --git a/src/core/zod/_runtime-emitter.ts b/src/core/zod/_runtime-emitter.ts
index 1c78f8a..d778994 100644
--- a/src/core/zod/_runtime-emitter.ts
+++ b/src/core/zod/_runtime-emitter.ts
@@ -33,7 +33,7 @@ export function createRuntimeZodEmitter(registry: RuntimeZodRegistry): ZodEmitte
intLiteral: value => z.literal(value),
booleanLiteral: value => z.literal(value),
- stringEnum: values => z.union(values.map(v => z.literal(v)) as [z.ZodLiteral, ...z.ZodLiteral[]]),
+ stringEnum: values => z.enum(values as [string, ...string[]]),
intEnum: values => z.union(values.map(v => z.literal(v)) as [z.ZodLiteral, ...z.ZodLiteral[]]),
tuple: elements => {
diff --git a/src/core/zod/build-zod-schema.ts b/src/core/zod/build-zod-schema.ts
index fa9e9fe..8580e13 100644
--- a/src/core/zod/build-zod-schema.ts
+++ b/src/core/zod/build-zod-schema.ts
@@ -53,6 +53,7 @@ export function buildZodFromType(type: schema.types.Type, emitter: ZodEmit
name: field.name,
value: buildZodFromType(field.type, emitter),
optional: field.optional,
+ docs: field.docs,
})),
type.additionalFields
);
diff --git a/src/core/zod/index.ts b/src/core/zod/index.ts
index c6b9c46..d9eb8fe 100644
--- a/src/core/zod/index.ts
+++ b/src/core/zod/index.ts
@@ -1,2 +1,3 @@
export { buildZodSchemaMap, buildZodFromType, type ZodSchemaMap } from './build-zod-schema.js';
export type { ZodEmitter } from './_emitter.js';
+export { createCodegenZodEmitter, type ZodCodegenEmitterConfig, type ZodVariant } from './_codegen-emitter.js';
diff --git a/src/errors/invalid-opts.ts b/src/errors/invalid-opts.ts
index 31143d5..f0f1188 100644
--- a/src/errors/invalid-opts.ts
+++ b/src/errors/invalid-opts.ts
@@ -4,11 +4,14 @@ import type {
GenerateRulesOption,
GenerateSwiftOption,
GenerateTsOption,
+ GenerateZodOption,
ValidateDataOption,
} from '../api/index.js';
import {
RULES_READONLY_FIELD_VALIDATOR_NAME_PATTERN_PARAM,
RULES_TYPE_VALIDATOR_NAME_PATTERN_PARAM,
+ ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM,
+ ZOD_SCHEMA_NAME_PATTERN_PARAM,
} from '../constants.js';
export class InvalidOptionsError extends Error {
@@ -38,6 +41,31 @@ export class InvalidPyIndentationOptionError extends InvalidOptionsError {
}
}
+export class InvalidZodIndentationOptionError extends InvalidOptionsError {
+ public constructor(indentation: number) {
+ const option: GenerateZodOption = 'indentation';
+ super(`Expected '${option}' to be a positive integer. Received ${indentation}`);
+ }
+}
+
+export class InvalidZodSchemaNamePatternOptionError extends InvalidOptionsError {
+ public constructor(pattern: string) {
+ const option: GenerateZodOption = 'schemaNamePattern';
+ super(
+ `Expected '${option}' to be a string that contains a '${ZOD_SCHEMA_NAME_PATTERN_PARAM}' substring. Received '${pattern}'`
+ );
+ }
+}
+
+export class InvalidZodInferredTypeNamePatternOptionError extends InvalidOptionsError {
+ public constructor(pattern: string) {
+ const option: GenerateZodOption = 'inferredTypeNamePattern';
+ super(
+ `Expected '${option}' to be a string that contains a '${ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM}' substring. Received '${pattern}'`
+ );
+ }
+}
+
export class InvalidRulesStartMarkerOptionError extends InvalidOptionsError {
public constructor() {
const option: GenerateRulesOption = 'startMarker';
diff --git a/src/generators/zod/__tests__/generator.test.ts b/src/generators/zod/__tests__/generator.test.ts
new file mode 100644
index 0000000..4054d86
--- /dev/null
+++ b/src/generators/zod/__tests__/generator.test.ts
@@ -0,0 +1,235 @@
+import type { TSGenerationTarget, ZodVariant } from '../../../api/index.js';
+import { schema } from '../../../schema/index.js';
+import { createZodGenerator } from '../_impl.js';
+
+function createGenerator(
+ overrides: {
+ target?: TSGenerationTarget;
+ variant?: ZodVariant;
+ schemaNamePattern?: string;
+ emitInferredTypes?: boolean;
+ inferredTypeNamePattern?: string;
+ } = {}
+) {
+ return createZodGenerator({
+ target: overrides.target ?? 'firebase-admin@13',
+ variant: overrides.variant ?? 'v4',
+ schemaNamePattern: overrides.schemaNamePattern ?? '{modelName}Schema',
+ emitInferredTypes: overrides.emitInferredTypes ?? false,
+ inferredTypeNamePattern: overrides.inferredTypeNamePattern ?? '{modelName}',
+ });
+}
+
+describe('ZodGeneratorImpl', () => {
+ it('produces an empty generation for an empty schema and reports no Firestore-typed fields', () => {
+ const generation = createGenerator().generate(schema.createSchema());
+ expect(generation).toEqual({
+ type: 'zod',
+ declarations: [],
+ usesTimestamp: false,
+ usesBytes: false,
+ });
+ });
+
+ it('emits one declaration per alias and document model with the right schemaName/modelKind', () => {
+ const s = schema.createSchemaFromDefinition({
+ Username: { model: 'alias', type: 'string' },
+ User: {
+ model: 'document',
+ path: 'users/{userId}',
+ type: { type: 'object', fields: { name: { type: 'Username' } } },
+ },
+ });
+
+ const generation = createGenerator().generate(s);
+ expect(generation.declarations).toHaveLength(2);
+
+ const username = generation.declarations.find(d => d.modelName === 'Username');
+ expect(username).toMatchObject({
+ type: 'schema',
+ schemaName: 'UsernameSchema',
+ inferredTypeName: null,
+ modelKind: 'alias',
+ modelDocs: null,
+ });
+
+ const user = generation.declarations.find(d => d.modelName === 'User');
+ expect(user).toMatchObject({
+ schemaName: 'UserSchema',
+ inferredTypeName: null,
+ modelKind: 'document',
+ });
+ });
+
+ describe('inferred types', () => {
+ it('leaves inferredTypeName null for every declaration when emitInferredTypes is false', () => {
+ const s = schema.createSchemaFromDefinition({
+ Username: { model: 'alias', type: 'string' },
+ User: {
+ model: 'document',
+ path: 'users/{userId}',
+ type: { type: 'object', fields: { name: { type: 'Username' } } },
+ },
+ });
+
+ const generation = createGenerator({ emitInferredTypes: false }).generate(s);
+ generation.declarations.forEach(d => {
+ expect(d.inferredTypeName).toBeNull();
+ });
+ });
+
+ it('populates inferredTypeName using the default pattern when emitInferredTypes is true', () => {
+ const s = schema.createSchemaFromDefinition({
+ Username: { model: 'alias', type: 'string' },
+ User: {
+ model: 'document',
+ path: 'users/{userId}',
+ type: { type: 'object', fields: { name: { type: 'Username' } } },
+ },
+ });
+
+ const generation = createGenerator({ emitInferredTypes: true }).generate(s);
+
+ expect(generation.declarations.find(d => d.modelName === 'Username')?.inferredTypeName).toBe('Username');
+ expect(generation.declarations.find(d => d.modelName === 'User')?.inferredTypeName).toBe('User');
+ });
+
+ it('honours a custom inferredTypeNamePattern', () => {
+ const s = schema.createSchemaFromDefinition({
+ User: {
+ model: 'document',
+ path: 'users/{userId}',
+ type: { type: 'object', fields: { name: { type: 'string' } } },
+ },
+ });
+
+ const generation = createGenerator({
+ emitInferredTypes: true,
+ inferredTypeNamePattern: 'I{modelName}',
+ }).generate(s);
+
+ expect(generation.declarations.find(d => d.modelName === 'User')?.inferredTypeName).toBe('IUser');
+ });
+ });
+
+ it('honours a custom schemaNamePattern when generating identifiers and reference expressions', () => {
+ const s = schema.createSchemaFromDefinition({
+ Username: { model: 'alias', type: 'string' },
+ User: {
+ model: 'document',
+ path: 'users/{userId}',
+ type: { type: 'object', fields: { name: { type: 'Username' } } },
+ },
+ });
+
+ const generation = createGenerator({ schemaNamePattern: 'z{modelName}' }).generate(s);
+ const userDecl = generation.declarations.find(d => d.modelName === 'User');
+ expect(userDecl?.schemaName).toBe('zUser');
+ // Reference into Username should use the same pattern
+ expect(userDecl?.expression).toContain('z.lazy(() => zUsername)');
+ });
+
+ it('appends .describe(...) to the right-hand expression when the model has docs', () => {
+ const s = schema.createSchemaFromDefinition({
+ Username: { model: 'alias', type: 'string', docs: 'A unique handle.' },
+ Plain: { model: 'alias', type: 'string' },
+ });
+
+ const generation = createGenerator().generate(s);
+ const documented = generation.declarations.find(d => d.modelName === 'Username');
+ const undocumented = generation.declarations.find(d => d.modelName === 'Plain');
+
+ expect(documented?.expression).toBe(`z.string().describe("A unique handle.")`);
+ expect(undocumented?.expression).toBe('z.string()');
+ });
+
+ it('threads field-level docs through as .describe(...) on the field value', () => {
+ const s = schema.createSchemaFromDefinition({
+ Profile: {
+ model: 'alias',
+ type: {
+ type: 'object',
+ fields: {
+ id: { type: 'string', docs: 'The profile ID' },
+ bio: { type: 'string', optional: true },
+ },
+ },
+ },
+ });
+
+ const generation = createGenerator({ variant: 'v4' }).generate(s);
+ const profile = generation.declarations.find(d => d.modelName === 'Profile');
+ expect(profile?.expression).toContain(`id: z.string().describe("The profile ID")`);
+ expect(profile?.expression).toContain(`bio: z.string().optional()`);
+ });
+
+ it('reports usesTimestamp/usesBytes accurately by walking every model type', () => {
+ const sBoth = schema.createSchemaFromDefinition({
+ Stamp: { model: 'alias', type: 'timestamp' },
+ Blob: { model: 'alias', type: 'bytes' },
+ });
+ const gBoth = createGenerator().generate(sBoth);
+ expect(gBoth.usesTimestamp).toBe(true);
+ expect(gBoth.usesBytes).toBe(true);
+
+ const sNone = schema.createSchemaFromDefinition({ Name: { model: 'alias', type: 'string' } });
+ const gNone = createGenerator().generate(sNone);
+ expect(gNone.usesTimestamp).toBe(false);
+ expect(gNone.usesBytes).toBe(false);
+ });
+
+ it('detects bytes/timestamp usage even when nested inside lists/maps/objects', () => {
+ const s = schema.createSchemaFromDefinition({
+ Doc: {
+ model: 'document',
+ path: 'docs/{docId}',
+ type: {
+ type: 'object',
+ fields: {
+ createdAt: { type: 'timestamp' },
+ attachments: { type: { type: 'list', elementType: 'bytes' } },
+ },
+ },
+ },
+ });
+ const generation = createGenerator().generate(s);
+ expect(generation.usesTimestamp).toBe(true);
+ expect(generation.usesBytes).toBe(true);
+ });
+
+ it('does not mutate the input schema', () => {
+ const s = schema.createSchemaFromDefinition({
+ Profile: {
+ model: 'document',
+ path: 'profiles/{profileId}',
+ type: { type: 'object', fields: { name: { type: 'string' } } },
+ },
+ });
+ const snapshot = s.clone();
+ createGenerator().generate(s);
+ expect(s.aliasModels).toEqual(snapshot.aliasModels);
+ expect(s.documentModels).toEqual(snapshot.documentModels);
+ });
+
+ it('emits matching expressions for both v3 and v4 except for object factory and record key', () => {
+ const s = schema.createSchemaFromDefinition({
+ Lookup: { model: 'alias', type: { type: 'map', valueType: 'int' } },
+ Box: {
+ model: 'alias',
+ type: { type: 'object', fields: { id: { type: 'string' } }, additionalFields: true },
+ },
+ });
+ const v3 = createGenerator({ variant: 'v3' }).generate(s);
+ const v4 = createGenerator({ variant: 'v4' }).generate(s);
+
+ const lookupV3 = v3.declarations.find(d => d.modelName === 'Lookup')?.expression;
+ const lookupV4 = v4.declarations.find(d => d.modelName === 'Lookup')?.expression;
+ expect(lookupV3).toBe('z.record(z.number().int())');
+ expect(lookupV4).toBe('z.record(z.string(), z.number().int())');
+
+ const boxV3 = v3.declarations.find(d => d.modelName === 'Box')?.expression;
+ const boxV4 = v4.declarations.find(d => d.modelName === 'Box')?.expression;
+ expect(boxV3).toContain(`.passthrough()`);
+ expect(boxV4?.startsWith('z.looseObject(')).toBe(true);
+ });
+});
diff --git a/src/generators/zod/_impl.ts b/src/generators/zod/_impl.ts
new file mode 100644
index 0000000..bf3f535
--- /dev/null
+++ b/src/generators/zod/_impl.ts
@@ -0,0 +1,75 @@
+import { ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM, ZOD_SCHEMA_NAME_PATTERN_PARAM } from '../../constants.js';
+import { buildZodFromType, createCodegenZodEmitter } from '../../core/zod/index.js';
+import { schema } from '../../schema/index.js';
+import { typeUsesBytes, typeUsesTimestamp } from './_type-traversal.js';
+import type { ZodGeneration, ZodGenerator, ZodGeneratorConfig, ZodSchemaDeclaration } from './_types.js';
+
+class ZodGeneratorImpl implements ZodGenerator {
+ public constructor(private readonly config: ZodGeneratorConfig) {}
+
+ public generate(s: schema.Schema): ZodGeneration {
+ const emitter = createCodegenZodEmitter({
+ variant: this.config.variant,
+ target: this.config.target,
+ getSchemaIdentifierForModel: name => this.getSchemaIdentifierForModel(name),
+ });
+
+ const declarations: ZodSchemaDeclaration[] = [];
+
+ s.aliasModels.forEach(model => {
+ declarations.push(this.createDeclaration(model, 'alias', emitter));
+ });
+
+ s.documentModels.forEach(model => {
+ declarations.push(this.createDeclaration(model, 'document', emitter));
+ });
+
+ const usesTimestamp = this.schemaUses(s, typeUsesTimestamp);
+ const usesBytes = this.schemaUses(s, typeUsesBytes);
+
+ return {
+ type: 'zod',
+ declarations,
+ usesTimestamp,
+ usesBytes,
+ };
+ }
+
+ private createDeclaration(
+ model: { name: string; docs: string | null; type: schema.types.Type },
+ modelKind: 'alias' | 'document',
+ emitter: ReturnType
+ ): ZodSchemaDeclaration {
+ const expression = this.attachModelDocs(buildZodFromType(model.type, emitter), model.docs);
+ return {
+ type: 'schema',
+ modelName: model.name,
+ schemaName: this.getSchemaIdentifierForModel(model.name),
+ inferredTypeName: this.config.emitInferredTypes ? this.getInferredTypeIdentifierForModel(model.name) : null,
+ modelDocs: model.docs,
+ expression,
+ modelKind,
+ };
+ }
+
+ private attachModelDocs(expression: string, docs: string | null): string {
+ if (docs === null || docs.length === 0) return expression;
+ return `${expression}.describe(${JSON.stringify(docs)})`;
+ }
+
+ private getSchemaIdentifierForModel(modelName: string): string {
+ return this.config.schemaNamePattern.replace(ZOD_SCHEMA_NAME_PATTERN_PARAM, modelName);
+ }
+
+ private getInferredTypeIdentifierForModel(modelName: string): string {
+ return this.config.inferredTypeNamePattern.replace(ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM, modelName);
+ }
+
+ private schemaUses(s: schema.Schema, predicate: (t: schema.types.Type) => boolean): boolean {
+ return s.aliasModels.some(m => predicate(m.type)) || s.documentModels.some(m => predicate(m.type));
+ }
+}
+
+export function createZodGenerator(config: ZodGeneratorConfig): ZodGenerator {
+ return new ZodGeneratorImpl(config);
+}
diff --git a/src/generators/zod/_type-traversal.ts b/src/generators/zod/_type-traversal.ts
new file mode 100644
index 0000000..80c6f3f
--- /dev/null
+++ b/src/generators/zod/_type-traversal.ts
@@ -0,0 +1,57 @@
+import { schema } from '../../schema/index.js';
+import { assertNever } from '../../util/assert.js';
+
+/**
+ * Recursively checks whether the given Typesync type tree contains a node of
+ * the specified primitive `kind`. Used by the Zod generator to decide whether
+ * the rendered file needs to import the Firestore SDK at all.
+ *
+ * Alias references are intentionally not followed: each alias model's own type
+ * is walked separately by the caller, so any `timestamp`/`bytes` usage is
+ * eventually visited at its definition site regardless of how many references
+ * point at it.
+ */
+function typeContains(type: schema.types.Type, kind: 'timestamp' | 'bytes'): boolean {
+ switch (type.type) {
+ case 'any':
+ case 'unknown':
+ case 'nil':
+ case 'string':
+ case 'boolean':
+ case 'int':
+ case 'double':
+ case 'string-literal':
+ case 'int-literal':
+ case 'boolean-literal':
+ case 'string-enum':
+ case 'int-enum':
+ case 'alias':
+ return false;
+ case 'timestamp':
+ return kind === 'timestamp';
+ case 'bytes':
+ return kind === 'bytes';
+ case 'tuple':
+ return type.elements.some(el => typeContains(el, kind));
+ case 'list':
+ return typeContains(type.elementType, kind);
+ case 'map':
+ return typeContains(type.valueType, kind);
+ case 'object':
+ return type.fields.some(f => typeContains(f.type, kind));
+ case 'simple-union':
+ return type.variants.some(v => typeContains(v, kind));
+ case 'discriminated-union':
+ return type.variants.some(v => typeContains(v, kind));
+ default:
+ assertNever(type);
+ }
+}
+
+export function typeUsesTimestamp(type: schema.types.Type): boolean {
+ return typeContains(type, 'timestamp');
+}
+
+export function typeUsesBytes(type: schema.types.Type): boolean {
+ return typeContains(type, 'bytes');
+}
diff --git a/src/generators/zod/_types.ts b/src/generators/zod/_types.ts
new file mode 100644
index 0000000..2095884
--- /dev/null
+++ b/src/generators/zod/_types.ts
@@ -0,0 +1,59 @@
+import type { TSGenerationTarget, ZodVariant } from '../../api/index.js';
+import type { schema } from '../../schema/index.js';
+
+export interface ZodSchemaDeclaration {
+ type: 'schema';
+ /** Original Typesync model name (e.g. `User`). */
+ modelName: string;
+ /**
+ * Identifier under which the schema is exported in the generated file
+ * (e.g. `UserSchema`). Derived from the schema name pattern.
+ */
+ schemaName: string;
+ /**
+ * Identifier under which the inferred TypeScript type is exported, if any
+ * (e.g. `User`). `null` when `emitInferredTypes` is disabled — in that case
+ * the renderer skips the inferred-type export entirely.
+ */
+ inferredTypeName: string | null;
+ /** Documentation attached to the model in the source schema, if any. */
+ modelDocs: string | null;
+ /**
+ * Pre-emitted Zod source code for the right-hand side of the export, e.g.
+ * `z.strictObject({ ... })` or `z.string().describe('A user name')`. Already
+ * includes any `.describe(...)` for the model-level docs.
+ */
+ expression: string;
+ /** Whether the underlying model is an alias model or a document model. */
+ modelKind: 'alias' | 'document';
+}
+
+export type ZodDeclaration = ZodSchemaDeclaration;
+
+export interface ZodGeneration {
+ type: 'zod';
+ declarations: ZodDeclaration[];
+ /**
+ * Whether the generated file references the Firestore `Timestamp` class
+ * anywhere. The renderer uses this to decide whether to emit the Firestore
+ * SDK import.
+ */
+ usesTimestamp: boolean;
+ /**
+ * Whether the generated file references the Firestore bytes class anywhere
+ * (`Buffer` for the admin SDK; `firestore.Bytes`/`firestore.Blob` otherwise).
+ */
+ usesBytes: boolean;
+}
+
+export interface ZodGeneratorConfig {
+ target: TSGenerationTarget;
+ variant: ZodVariant;
+ schemaNamePattern: string;
+ emitInferredTypes: boolean;
+ inferredTypeNamePattern: string;
+}
+
+export interface ZodGenerator {
+ generate(s: schema.Schema): ZodGeneration;
+}
diff --git a/src/generators/zod/index.ts b/src/generators/zod/index.ts
new file mode 100644
index 0000000..32818b6
--- /dev/null
+++ b/src/generators/zod/index.ts
@@ -0,0 +1,2 @@
+export { createZodGenerator } from './_impl.js';
+export type * from './_types.js';
diff --git a/src/renderers/_namespace.ts b/src/renderers/_namespace.ts
index afec54b..e07dc19 100644
--- a/src/renderers/_namespace.ts
+++ b/src/renderers/_namespace.ts
@@ -3,4 +3,5 @@ export * from './python/index.js';
export * from './rules/index.js';
export * from './ts/index.js';
export * from './swift/index.js';
+export * from './zod/index.js';
export type * from './_types.js';
diff --git a/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-inferred-types.ts b/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-inferred-types.ts
new file mode 100644
index 0000000..3deb8d6
--- /dev/null
+++ b/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-inferred-types.ts
@@ -0,0 +1,8 @@
+import { z } from 'zod';
+
+/** Unique handle. */
+export const UsernameSchema = z.string().describe('Unique handle.');
+export type Username = z.infer;
+
+export const UserSchema = z.strictObject({ name: z.lazy(() => UsernameSchema), age: z.number().int().optional() });
+export type User = z.infer;
diff --git a/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-v3.ts b/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-v3.ts
new file mode 100644
index 0000000..fbe16cb
--- /dev/null
+++ b/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-v3.ts
@@ -0,0 +1,5 @@
+import { z } from 'zod';
+
+export const LookupSchema = z.record(z.number().int());
+
+export const LooseSchema = z.object({ id: z.string() }).passthrough();
diff --git a/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-v4.ts b/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-v4.ts
new file mode 100644
index 0000000..690a1dc
--- /dev/null
+++ b/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-v4.ts
@@ -0,0 +1,29 @@
+import * as firestore from 'firebase-admin/firestore';
+import { z } from 'zod';
+
+/** A user-interaction event. */
+export const EventSchema = z
+ .discriminatedUnion('kind', [
+ z.strictObject({ kind: z.literal('click'), x: z.number().int() }),
+ z.strictObject({ kind: z.literal('scroll'), dy: z.number().int() }),
+ ])
+ .describe('A user-interaction event.');
+
+export const RoleSchema = z.enum(['admin', 'user']);
+
+export const TagSchema = z.array(z.string());
+
+/** Unique user handle. */
+export const UsernameSchema = z.string().describe('Unique user handle.');
+
+/** A user document. */
+export const UserSchema = z
+ .strictObject({
+ username: z.lazy(() => UsernameSchema).describe("The user's chosen handle."),
+ role: z.lazy(() => RoleSchema),
+ tags: z.lazy(() => TagSchema).optional(),
+ createdAt: z.instanceof(firestore.Timestamp).describe('When the user signed up.'),
+ avatar: z.instanceof(Buffer).optional(),
+ bio: z.record(z.string(), z.string()).optional(),
+ })
+ .describe('A user document.');
diff --git a/src/renderers/zod/__tests__/__file_snapshots__/indentation-4.ts b/src/renderers/zod/__tests__/__file_snapshots__/indentation-4.ts
new file mode 100644
index 0000000..ffe4973
--- /dev/null
+++ b/src/renderers/zod/__tests__/__file_snapshots__/indentation-4.ts
@@ -0,0 +1,7 @@
+import { z } from 'zod';
+
+export const ProfileSchema = z.strictObject({
+ id: z.string().describe('The profile ID'),
+ bio: z.string().optional().describe('A short bio about the user'),
+ avatarUrl: z.string().optional(),
+});
diff --git a/src/renderers/zod/__tests__/__file_snapshots__/v4-with-docs.ts b/src/renderers/zod/__tests__/__file_snapshots__/v4-with-docs.ts
new file mode 100644
index 0000000..c5caa09
--- /dev/null
+++ b/src/renderers/zod/__tests__/__file_snapshots__/v4-with-docs.ts
@@ -0,0 +1,13 @@
+import * as firestore from 'firebase-admin/firestore';
+import { z } from 'zod';
+
+/** A unique handle. */
+export const UsernameSchema = z.string().describe('A unique handle.');
+
+/** A user document. */
+export const UserSchema = z
+ .strictObject({
+ name: z.lazy(() => UsernameSchema).describe('The user name'),
+ createdAt: z.instanceof(firestore.Timestamp),
+ })
+ .describe('A user document.');
diff --git a/src/renderers/zod/__tests__/__file_snapshots__/with-inferred-types.ts b/src/renderers/zod/__tests__/__file_snapshots__/with-inferred-types.ts
new file mode 100644
index 0000000..5930f41
--- /dev/null
+++ b/src/renderers/zod/__tests__/__file_snapshots__/with-inferred-types.ts
@@ -0,0 +1,8 @@
+import { z } from 'zod';
+
+/** A unique handle. */
+export const UsernameSchema = z.string().describe('A unique handle.');
+export type Username = z.infer;
+
+export const UserSchema = z.strictObject({ name: z.lazy(() => UsernameSchema) });
+export type User = z.infer;
diff --git a/src/renderers/zod/__tests__/end-to-end.test.ts b/src/renderers/zod/__tests__/end-to-end.test.ts
new file mode 100644
index 0000000..8caca55
--- /dev/null
+++ b/src/renderers/zod/__tests__/end-to-end.test.ts
@@ -0,0 +1,118 @@
+import type { TSGenerationTarget, ZodVariant } from '../../../api/index.js';
+import { createZodGenerator } from '../../../generators/zod/index.js';
+import { schema } from '../../../schema/index.js';
+import { createZodRenderer } from '../_impl.js';
+
+async function generateAndRender(
+ s: schema.Schema,
+ config: {
+ target?: TSGenerationTarget;
+ variant?: ZodVariant;
+ indentation?: number;
+ emitInferredTypes?: boolean;
+ inferredTypeNamePattern?: string;
+ } = {}
+) {
+ const target = config.target ?? 'firebase-admin@13';
+ const variant = config.variant ?? 'v4';
+ const indentation = config.indentation ?? 2;
+ const generation = createZodGenerator({
+ target,
+ variant,
+ schemaNamePattern: '{modelName}Schema',
+ emitInferredTypes: config.emitInferredTypes ?? false,
+ inferredTypeNamePattern: config.inferredTypeNamePattern ?? '{modelName}',
+ }).generate(s);
+ const file = await createZodRenderer({ target, variant, indentation }).render(generation);
+ return file.content;
+}
+
+describe('Zod generator + renderer end-to-end', () => {
+ it('produces a representative v4 file from a real schema', async () => {
+ const s = schema.createSchemaFromDefinition({
+ Username: { model: 'alias', type: 'string', docs: 'Unique user handle.' },
+ Role: {
+ model: 'alias',
+ type: {
+ type: 'enum',
+ members: [
+ { label: 'Admin', value: 'admin' },
+ { label: 'User', value: 'user' },
+ ],
+ },
+ },
+ Tag: { model: 'alias', type: { type: 'list', elementType: 'string' } },
+ Event: {
+ model: 'alias',
+ type: {
+ type: 'union',
+ discriminant: 'kind',
+ variants: [
+ {
+ type: 'object',
+ fields: {
+ kind: { type: { type: 'literal', value: 'click' } },
+ x: { type: 'int' },
+ },
+ },
+ {
+ type: 'object',
+ fields: {
+ kind: { type: { type: 'literal', value: 'scroll' } },
+ dy: { type: 'int' },
+ },
+ },
+ ],
+ },
+ docs: 'A user-interaction event.',
+ },
+ User: {
+ model: 'document',
+ path: 'users/{userId}',
+ docs: 'A user document.',
+ type: {
+ type: 'object',
+ additionalFields: false,
+ fields: {
+ username: { type: 'Username', docs: "The user's chosen handle." },
+ role: { type: 'Role' },
+ tags: { type: 'Tag', optional: true },
+ createdAt: { type: 'timestamp', docs: 'When the user signed up.' },
+ avatar: { type: 'bytes', optional: true },
+ bio: { type: { type: 'map', valueType: 'string' }, optional: true },
+ },
+ },
+ },
+ });
+
+ const content = await generateAndRender(s);
+ await expect(content).toMatchFileSnapshot('./__file_snapshots__/end-to-end-v4.ts');
+ });
+
+ it('produces a v3 file with .strict()/.passthrough() and the v3 z.record signature', async () => {
+ const s = schema.createSchemaFromDefinition({
+ Lookup: { model: 'alias', type: { type: 'map', valueType: 'int' } },
+ Loose: {
+ model: 'alias',
+ type: { type: 'object', additionalFields: true, fields: { id: { type: 'string' } } },
+ },
+ });
+
+ const content = await generateAndRender(s, { variant: 'v3' });
+ await expect(content).toMatchFileSnapshot('./__file_snapshots__/end-to-end-v3.ts');
+ });
+
+ it('emits `export type X = z.infer;` after each schema when emitInferredTypes is true', async () => {
+ const s = schema.createSchemaFromDefinition({
+ Username: { model: 'alias', type: 'string', docs: 'Unique handle.' },
+ User: {
+ model: 'document',
+ path: 'users/{userId}',
+ type: { type: 'object', fields: { name: { type: 'Username' }, age: { type: 'int', optional: true } } },
+ },
+ });
+
+ const content = await generateAndRender(s, { emitInferredTypes: true });
+ await expect(content).toMatchFileSnapshot('./__file_snapshots__/end-to-end-inferred-types.ts');
+ });
+});
diff --git a/src/renderers/zod/__tests__/renderer.test.ts b/src/renderers/zod/__tests__/renderer.test.ts
new file mode 100644
index 0000000..3866c5f
--- /dev/null
+++ b/src/renderers/zod/__tests__/renderer.test.ts
@@ -0,0 +1,180 @@
+import type { TSGenerationTarget, ZodVariant } from '../../../api/index.js';
+import type { ZodGeneration } from '../../../generators/zod/index.js';
+import { createZodRenderer } from '../_impl.js';
+
+function createRenderer(overrides: { indentation?: number; target?: TSGenerationTarget; variant?: ZodVariant } = {}) {
+ return createZodRenderer({
+ indentation: overrides.indentation ?? 2,
+ target: overrides.target ?? 'firebase-admin@13',
+ variant: overrides.variant ?? 'v4',
+ });
+}
+
+describe('ZodRendererImpl', () => {
+ it('renders a v4 schema file with Firestore SDK + zod imports, model docs, and field-level describe()', async () => {
+ const generation: ZodGeneration = {
+ type: 'zod',
+ usesTimestamp: true,
+ usesBytes: false,
+ declarations: [
+ {
+ type: 'schema',
+ modelName: 'Username',
+ schemaName: 'UsernameSchema',
+ inferredTypeName: null,
+ modelDocs: 'A unique handle.',
+ modelKind: 'alias',
+ expression: `z.string().describe("A unique handle.")`,
+ },
+ {
+ type: 'schema',
+ modelName: 'User',
+ schemaName: 'UserSchema',
+ inferredTypeName: null,
+ modelDocs: 'A user document.',
+ modelKind: 'document',
+ expression: `z.strictObject({ name: z.lazy(() => UsernameSchema).describe("The user name"), createdAt: z.instanceof(firestore.Timestamp) }).describe("A user document.")`,
+ },
+ ],
+ };
+
+ const result = await createRenderer().render(generation);
+ await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/v4-with-docs.ts');
+ });
+
+ it('emits an inferred TS type after each schema export when inferredTypeName is set', async () => {
+ const generation: ZodGeneration = {
+ type: 'zod',
+ usesTimestamp: false,
+ usesBytes: false,
+ declarations: [
+ {
+ type: 'schema',
+ modelName: 'Username',
+ schemaName: 'UsernameSchema',
+ inferredTypeName: 'Username',
+ modelDocs: 'A unique handle.',
+ modelKind: 'alias',
+ expression: `z.string().describe("A unique handle.")`,
+ },
+ {
+ type: 'schema',
+ modelName: 'User',
+ schemaName: 'UserSchema',
+ inferredTypeName: 'User',
+ modelDocs: null,
+ modelKind: 'document',
+ expression: `z.strictObject({ name: z.lazy(() => UsernameSchema) })`,
+ },
+ ],
+ };
+
+ const result = await createRenderer().render(generation);
+ await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/with-inferred-types.ts');
+ });
+
+ it('omits the Firestore SDK import when no model uses timestamp or bytes', async () => {
+ const generation: ZodGeneration = {
+ type: 'zod',
+ usesTimestamp: false,
+ usesBytes: false,
+ declarations: [
+ {
+ type: 'schema',
+ modelName: 'Username',
+ schemaName: 'UsernameSchema',
+ inferredTypeName: null,
+ modelDocs: null,
+ modelKind: 'alias',
+ expression: 'z.string()',
+ },
+ ],
+ };
+
+ const result = await createRenderer().render(generation);
+ expect(result.content).not.toContain('firestore');
+ expect(result.content).toContain(`import { z } from 'zod';`);
+ });
+
+ it('skips the Firestore SDK import for the admin target when only bytes are used (Buffer is a Node global)', async () => {
+ const generation: ZodGeneration = {
+ type: 'zod',
+ usesTimestamp: false,
+ usesBytes: true,
+ declarations: [
+ {
+ type: 'schema',
+ modelName: 'Avatar',
+ schemaName: 'AvatarSchema',
+ inferredTypeName: null,
+ modelDocs: null,
+ modelKind: 'alias',
+ expression: 'z.instanceof(Buffer)',
+ },
+ ],
+ };
+
+ const result = await createRenderer({ target: 'firebase-admin@13' }).render(generation);
+ expect(result.content).not.toContain(`firebase-admin/firestore`);
+ });
+
+ it('emits the right Firestore SDK import for each target family', async () => {
+ const targetsByExpectedImport: Record = {
+ [`import * as firestore from 'firebase-admin/firestore';`]: ['firebase-admin@13', 'firebase-admin@12'],
+ [`import { firestore } from 'firebase-admin';`]: ['firebase-admin@11', 'firebase-admin@10'],
+ [`import * as firestore from 'firebase/firestore';`]: ['firebase@11', 'firebase@10', 'firebase@9'],
+ [`import * as firestore from '@react-native-firebase/firestore';`]: [
+ 'react-native-firebase@21',
+ 'react-native-firebase@20',
+ 'react-native-firebase@19',
+ ],
+ };
+
+ const generation: ZodGeneration = {
+ type: 'zod',
+ usesTimestamp: true,
+ usesBytes: false,
+ declarations: [
+ {
+ type: 'schema',
+ modelName: 'Stamp',
+ schemaName: 'StampSchema',
+ inferredTypeName: null,
+ modelDocs: null,
+ modelKind: 'alias',
+ expression: 'z.instanceof(firestore.Timestamp)',
+ },
+ ],
+ };
+
+ for (const [expectedImport, targets] of Object.entries(targetsByExpectedImport)) {
+ for (const target of targets) {
+ const result = await createRenderer({ target }).render(generation);
+ expect(result.content.split('\n')[0]).toBe(expectedImport);
+ }
+ }
+ });
+
+ it('respects the configured indentation', async () => {
+ const generation: ZodGeneration = {
+ type: 'zod',
+ usesTimestamp: false,
+ usesBytes: false,
+ declarations: [
+ {
+ type: 'schema',
+ modelName: 'Profile',
+ schemaName: 'ProfileSchema',
+ inferredTypeName: null,
+ modelDocs: null,
+ modelKind: 'alias',
+ // Long expression to force prettier to wrap it across multiple lines.
+ expression: `z.strictObject({ id: z.string().describe("The profile ID"), bio: z.string().optional().describe("A short bio about the user"), avatarUrl: z.string().optional() })`,
+ },
+ ],
+ };
+
+ const result = await createRenderer({ indentation: 4 }).render(generation);
+ await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/indentation-4.ts');
+ });
+});
diff --git a/src/renderers/zod/__tests__/runtime-roundtrip.test.ts b/src/renderers/zod/__tests__/runtime-roundtrip.test.ts
new file mode 100644
index 0000000..b8a237f
--- /dev/null
+++ b/src/renderers/zod/__tests__/runtime-roundtrip.test.ts
@@ -0,0 +1,122 @@
+import { Timestamp } from 'firebase-admin/firestore';
+import { z } from 'zod';
+
+import { createZodGenerator } from '../../../generators/zod/index.js';
+import { schema } from '../../../schema/index.js';
+import { createZodRenderer } from '../_impl.js';
+
+/**
+ * Loads the rendered Zod source as actual JavaScript and returns the named
+ * schema export. We strip the import statements (we already have `z` and
+ * `firestore` in scope) and evaluate the rest in a Function so that the
+ * generated module has access to whatever bindings we provide. This catches
+ * regressions where the rendered source is well-formed text but doesn't
+ * actually compose into a valid Zod schema at runtime.
+ */
+function loadGeneratedSchema(source: string, schemaName: string): z.ZodTypeAny {
+ // The generator may emit `import` statements (we already have `z` and
+ // `firestore` in scope) and TypeScript-only `export type` aliases (not valid
+ // JS). Strip both so the rest can be eval'd as plain JS.
+ const stripped = source
+ .split('\n')
+ .filter(line => !line.startsWith('import ') && !line.startsWith('export type '))
+ .join('\n')
+ .replace(/^export const /gm, 'const ');
+
+ const factory = new Function('z', 'firestore', 'Buffer', `${stripped}\nreturn ${schemaName};`) as (
+ zArg: unknown,
+ firestoreArg: unknown,
+ BufferArg: unknown
+ ) => z.ZodTypeAny;
+
+ return factory(z, { Timestamp }, Buffer);
+}
+
+describe('runtime round-trip of generated Zod source', () => {
+ it('the v4 source parses valid documents and rejects invalid ones', async () => {
+ const s = schema.createSchemaFromDefinition({
+ Username: { model: 'alias', type: 'string' },
+ User: {
+ model: 'document',
+ path: 'users/{userId}',
+ type: {
+ type: 'object',
+ fields: {
+ username: { type: 'Username' },
+ createdAt: { type: 'timestamp' },
+ age: { type: 'int', optional: true },
+ },
+ },
+ },
+ });
+
+ const generation = createZodGenerator({
+ target: 'firebase-admin@13',
+ variant: 'v4',
+ schemaNamePattern: '{modelName}Schema',
+ emitInferredTypes: false,
+ inferredTypeNamePattern: '{modelName}',
+ }).generate(s);
+ const file = await createZodRenderer({
+ target: 'firebase-admin@13',
+ variant: 'v4',
+ indentation: 2,
+ }).render(generation);
+
+ const UserSchema = loadGeneratedSchema(file.content, 'UserSchema');
+ const ts = new Timestamp(1_700_000_000, 0);
+
+ expect(UserSchema.safeParse({ username: 'alice', createdAt: ts }).success).toBe(true);
+ expect(UserSchema.safeParse({ username: 'alice', createdAt: ts, age: 30 }).success).toBe(true);
+ expect(UserSchema.safeParse({ username: 'alice', createdAt: ts, extra: 'oops' }).success).toBe(false);
+ expect(UserSchema.safeParse({ username: 1, createdAt: ts }).success).toBe(false);
+ expect(UserSchema.safeParse({ username: 'alice' }).success).toBe(false);
+ });
+
+ it('the v4 source preserves model docs in the schema description metadata', async () => {
+ const s = schema.createSchemaFromDefinition({
+ Username: { model: 'alias', type: 'string', docs: 'Unique handle.' },
+ });
+ const generation = createZodGenerator({
+ target: 'firebase-admin@13',
+ variant: 'v4',
+ schemaNamePattern: '{modelName}Schema',
+ emitInferredTypes: false,
+ inferredTypeNamePattern: '{modelName}',
+ }).generate(s);
+ const file = await createZodRenderer({
+ target: 'firebase-admin@13',
+ variant: 'v4',
+ indentation: 2,
+ }).render(generation);
+
+ const UsernameSchema = loadGeneratedSchema(file.content, 'UsernameSchema');
+ expect(UsernameSchema.description).toBe('Unique handle.');
+ });
+
+ it('emits a `type` export that strips at runtime but keeps the schema parseable when emitInferredTypes is true', async () => {
+ const s = schema.createSchemaFromDefinition({
+ Username: { model: 'alias', type: 'string' },
+ });
+ const generation = createZodGenerator({
+ target: 'firebase-admin@13',
+ variant: 'v4',
+ schemaNamePattern: '{modelName}Schema',
+ emitInferredTypes: true,
+ inferredTypeNamePattern: '{modelName}',
+ }).generate(s);
+ const file = await createZodRenderer({
+ target: 'firebase-admin@13',
+ variant: 'v4',
+ indentation: 2,
+ }).render(generation);
+
+ // Sanity check: the rendered source contains both the schema const and the type alias.
+ expect(file.content).toContain('export const UsernameSchema =');
+ expect(file.content).toContain('export type Username = z.infer;');
+
+ const UsernameSchema = loadGeneratedSchema(file.content, 'UsernameSchema');
+ expect(UsernameSchema.safeParse('alice').success).toBe(true);
+ expect(UsernameSchema.safeParse(42).success).toBe(false);
+ });
+});
diff --git a/src/renderers/zod/_impl.ts b/src/renderers/zod/_impl.ts
new file mode 100644
index 0000000..b9d34c8
--- /dev/null
+++ b/src/renderers/zod/_impl.ts
@@ -0,0 +1,120 @@
+import { StringBuilder } from '@proficient/ds';
+import { format } from 'prettier';
+
+import type { TSGenerationTarget } from '../../api/index.js';
+import type { ZodGeneration, ZodSchemaDeclaration } from '../../generators/zod/index.js';
+import { assertNever } from '../../util/assert.js';
+import type { RenderedFile } from '../_types.js';
+import type { ZodRenderer, ZodRendererConfig } from './_types.js';
+
+class ZodRendererImpl implements ZodRenderer {
+ public constructor(private readonly config: ZodRendererConfig) {}
+
+ public async render(g: ZodGeneration): Promise {
+ const b = new StringBuilder();
+
+ const imports = this.renderImports(g);
+ if (imports.length > 0) {
+ b.append(`${imports}\n\n`);
+ }
+
+ g.declarations.forEach(declaration => {
+ b.append(`${this.renderDeclaration(declaration)}\n\n`);
+ });
+
+ const content = b.toString();
+ const formattedContent = await format(content, {
+ parser: 'typescript',
+ tabWidth: this.config.indentation,
+ trailingComma: 'es5',
+ printWidth: 120,
+ singleQuote: true,
+ });
+
+ return { content: formattedContent };
+ }
+
+ private renderDeclaration(declaration: ZodSchemaDeclaration): string {
+ let output = '';
+ if (declaration.modelDocs !== null) {
+ output += `/** ${declaration.modelDocs} */\n`;
+ }
+ output += `export const ${declaration.schemaName} = ${declaration.expression};`;
+ if (declaration.inferredTypeName !== null) {
+ output += `\nexport type ${declaration.inferredTypeName} = z.infer;`;
+ }
+ return output;
+ }
+
+ private renderImports(g: ZodGeneration): string {
+ const lines: string[] = [];
+
+ const firestoreImport = this.getFirestoreImportStatement(g);
+ if (firestoreImport !== null) {
+ lines.push(firestoreImport);
+ }
+
+ lines.push(`import { z } from 'zod';`);
+ return lines.join('\n');
+ }
+
+ /**
+ * Computes the Firestore SDK import (if any) needed by the rendered file. We
+ * intentionally key off the generation's `usesTimestamp` / `usesBytes` flags
+ * rather than scanning the rendered string so that the renderer stays in lock
+ * step with the generator's intent and emits no dead imports.
+ */
+ private getFirestoreImportStatement(g: ZodGeneration): string | null {
+ const needsTimestampImport = g.usesTimestamp;
+ const needsBytesImport = g.usesBytes && bytesRequiresFirestoreImport(this.config.target);
+
+ if (!needsTimestampImport && !needsBytesImport) return null;
+
+ return getFirestoreImportForTarget(this.config.target);
+ }
+}
+
+function bytesRequiresFirestoreImport(target: TSGenerationTarget): boolean {
+ switch (target) {
+ case 'firebase-admin@13':
+ case 'firebase-admin@12':
+ case 'firebase-admin@11':
+ case 'firebase-admin@10':
+ // Bytes use the global Node `Buffer` on admin, so no SDK import is needed.
+ return false;
+ case 'firebase@11':
+ case 'firebase@10':
+ case 'firebase@9':
+ case 'react-native-firebase@21':
+ case 'react-native-firebase@20':
+ case 'react-native-firebase@19':
+ return true;
+ default:
+ assertNever(target);
+ }
+}
+
+function getFirestoreImportForTarget(target: TSGenerationTarget): string {
+ switch (target) {
+ case 'firebase-admin@13':
+ case 'firebase-admin@12':
+ return `import * as firestore from 'firebase-admin/firestore';`;
+ case 'firebase-admin@11':
+ case 'firebase-admin@10':
+ return `import { firestore } from 'firebase-admin';`;
+ case 'firebase@11':
+ case 'firebase@10':
+ case 'firebase@9':
+ return `import * as firestore from 'firebase/firestore';`;
+ case 'react-native-firebase@21':
+ case 'react-native-firebase@20':
+ case 'react-native-firebase@19':
+ return `import * as firestore from '@react-native-firebase/firestore';`;
+ default:
+ assertNever(target);
+ }
+}
+
+export function createZodRenderer(config: ZodRendererConfig): ZodRenderer {
+ return new ZodRendererImpl(config);
+}
diff --git a/src/renderers/zod/_namespace.ts b/src/renderers/zod/_namespace.ts
new file mode 100644
index 0000000..12296d1
--- /dev/null
+++ b/src/renderers/zod/_namespace.ts
@@ -0,0 +1,2 @@
+export * from './_impl.js';
+export type * from './_types.js';
diff --git a/src/renderers/zod/_types.ts b/src/renderers/zod/_types.ts
new file mode 100644
index 0000000..c3d5778
--- /dev/null
+++ b/src/renderers/zod/_types.ts
@@ -0,0 +1,13 @@
+import type { TSGenerationTarget, ZodVariant } from '../../api/index.js';
+import type { ZodGeneration } from '../../generators/zod/index.js';
+import type { RenderedFile } from '../_types.js';
+
+export interface ZodRendererConfig {
+ target: TSGenerationTarget;
+ variant: ZodVariant;
+ indentation: number;
+}
+
+export interface ZodRenderer {
+ render(g: ZodGeneration): Promise;
+}
diff --git a/src/renderers/zod/index.ts b/src/renderers/zod/index.ts
new file mode 100644
index 0000000..6b1def7
--- /dev/null
+++ b/src/renderers/zod/index.ts
@@ -0,0 +1 @@
+export * from './_namespace.js';
diff --git a/tests/integration/zod/README.md b/tests/integration/zod/README.md
new file mode 100644
index 0000000..1af8bf0
--- /dev/null
+++ b/tests/integration/zod/README.md
@@ -0,0 +1,70 @@
+# Zod integration suite
+
+Integration tests for `typesync generate-zod`. Verifies that the generated Zod schemas:
+
+1. **Compile cleanly** under both Zod v3 and v4 with strict TypeScript (`tsc --noEmit`).
+2. **Parse the right wire shapes** in pure-Zod tests (no emulator) — both positive and negative cases for primitives,
+ enums, lists, records, nested objects, optionals, additional fields, simple unions, and discriminated unions.
+3. **Round-trip through the Firestore emulator** when the underlying types involve Firestore-native values
+ (`Timestamp`, `Bytes`/`Buffer`). Each generated schema is exercised against the same emulator-backed document the
+ `generate-ts` integration suite uses, so we know the schema accepts the actual wire output of the SDK rather than
+ what we *think* the wire shape is.
+
+## Layout
+
+```
+tests/integration/zod/
+ _fixtures/schemas/ Zod-only fixtures (e.g. discriminated unions) that
+ other generators don't have round-trip coverage for.
+ generated/
+ v3/.ts Schemas emitted with --variant v3 (admin SDK).
+ v4/.ts Schemas emitted with --variant v4 (admin SDK).
+ v4-web/.ts Schemas emitted with --variant v4 (firebase web SDK).
+ Only emitted for the `secrets` fixture today.
+ tests/
+ *.v3.test.ts Pure-Zod parsing tests for the v3 outputs.
+ *.v4.test.ts Pure-Zod parsing tests for the v4 outputs.
+ *.admin.test.ts Firestore emulator round-trips parsed with the
+ firebase-admin v4 schemas.
+ *.web.test.ts Same, for the firebase web SDK v4 schemas.
+```
+
+The shared fixtures (`_fixtures/schemas/users.yml`, `projects.yml`, `secrets.yml` at the parent level) are pulled in
+automatically alongside the zod-only fixtures.
+
+## How v3 and v4 coexist in one Node project
+
+Zod v3 and Zod v4 are incompatible at the import level. To exercise both in one place we install them as separate
+aliased packages (`zod-v3`, `zod-v4`) at the repo root, and the integration orchestrator
+(`scripts/integration-test.ts`) rewrites every `from 'zod'` in the generated files to either `from 'zod-v3'` or
+`from 'zod-v4'` depending on the subdir. The generated files thus look like:
+
+```ts
+// generated/v3/users.ts
+import { z } from 'zod-v3'; // ← rewritten by the orchestrator
+// ...
+
+// generated/v4/users.ts
+import { z } from 'zod-v4'; // ← rewritten by the orchestrator
+// ...
+```
+
+The post-processing is integration-only — every other consumer (`yarn build`, the published CLI, etc.) emits the
+canonical `from 'zod'` form.
+
+## Running
+
+From the repo root:
+
+```bash
+yarn test:integration:zod
+```
+
+That script:
+
+1. Generates v3, v4, and v4-web Zod schemas for each fixture under
+ `tests/integration/_fixtures/schemas/` and `tests/integration/zod/_fixtures/schemas/`.
+2. Rewrites the `zod` import in each generated file to its versioned alias.
+3. Runs `tsc --noEmit` against the suite's tsconfig.
+4. Boots the Firestore emulator via `firebase emulators:exec`.
+5. Runs `vitest` against every test under `tests/`.
diff --git a/tests/integration/zod/_fixtures/samples/catalog/ebook-digital.json b/tests/integration/zod/_fixtures/samples/catalog/ebook-digital.json
new file mode 100644
index 0000000..e05db35
--- /dev/null
+++ b/tests/integration/zod/_fixtures/samples/catalog/ebook-digital.json
@@ -0,0 +1,18 @@
+{
+ "id": "prod_ebook_42",
+ "name": "Mastering Firestore",
+ "price": {
+ "amount_minor": 1299,
+ "currency": "EUR"
+ },
+ "tags": ["ebook", "tech"],
+ "attributes": {
+ "language": "en"
+ },
+ "details": {
+ "kind": "digital",
+ "download_url": "https://example.com/files/ebook-42.epub",
+ "file_size_bytes": 2345678
+ },
+ "created_at": "2024-08-02T08:00:00.000Z"
+}
diff --git a/tests/integration/zod/_fixtures/samples/catalog/widget-physical.json b/tests/integration/zod/_fixtures/samples/catalog/widget-physical.json
new file mode 100644
index 0000000..4bc00c7
--- /dev/null
+++ b/tests/integration/zod/_fixtures/samples/catalog/widget-physical.json
@@ -0,0 +1,25 @@
+{
+ "id": "prod_widget_001",
+ "name": "Industrial Widget",
+ "price": {
+ "amount_minor": 4999,
+ "currency": "USD"
+ },
+ "rating": 4,
+ "tags": ["industrial", "imperial", "heavy-duty"],
+ "attributes": {
+ "color": "graphite",
+ "warranty_years": 5,
+ "fragile": false
+ },
+ "details": {
+ "kind": "physical",
+ "weight_grams": 1500,
+ "inventory": [
+ { "sku": "WID-A", "quantity": 12 },
+ { "sku": "WID-B", "quantity": 4 }
+ ],
+ "is_featured": true
+ },
+ "created_at": "2024-08-01T12:34:56.000Z"
+}
diff --git a/tests/integration/zod/_fixtures/schemas/catalog.yml b/tests/integration/zod/_fixtures/schemas/catalog.yml
new file mode 100644
index 0000000..d37331c
--- /dev/null
+++ b/tests/integration/zod/_fixtures/schemas/catalog.yml
@@ -0,0 +1,165 @@
+# yaml-language-server: $schema=../../../../../schema.local.json
+#
+# Zod-only integration fixture. Designed to exercise as much of the schema
+# surface as is meaningful to validate at runtime through a Zod schema:
+#
+# * string + int enums
+# * alias references (with z.lazy roundtrips)
+# * discriminated unions over a literal field
+# * simple unions (any-of without a tag)
+# * lists of primitives, lists of aliases, lists of objects
+# * records (maps) with primitive values
+# * nested objects
+# * optional fields
+# * top-level `additionalFields: true` (passthrough / loose object)
+# * literal value fields (string, int, boolean)
+#
+# This file lives under the Zod-only fixtures dir so that the Python / Swift /
+# TypeScript integration suites do not have to add round-trip coverage for
+# these shapes (they already cover the simpler shared fixtures under
+# tests/integration/_fixtures/schemas/).
+
+ProductId:
+ model: alias
+ docs: Stable identifier for a catalog product.
+ type: string
+
+Currency:
+ model: alias
+ docs: ISO-4217 currency code.
+ type:
+ type: enum
+ members:
+ - { label: USD, value: USD }
+ - { label: EUR, value: EUR }
+ - { label: GBP, value: GBP }
+
+Money:
+ model: alias
+ docs: A monetary amount expressed in a given currency.
+ type:
+ type: object
+ fields:
+ amount_minor:
+ type: int
+ docs: Amount in the currency's minor unit (e.g. cents for USD).
+ currency:
+ type: Currency
+
+Rating:
+ model: alias
+ docs: Customer rating expressed as an integer 1..5.
+ type:
+ type: enum
+ members:
+ - { label: One, value: 1 }
+ - { label: Two, value: 2 }
+ - { label: Three, value: 3 }
+ - { label: Four, value: 4 }
+ - { label: Five, value: 5 }
+
+InventoryEntry:
+ model: alias
+ type:
+ type: object
+ fields:
+ sku:
+ type: string
+ quantity:
+ type: int
+
+Tag:
+ model: alias
+ type: string
+
+# Simple (non-discriminated) union: a per-product attribute can be any of a
+# handful of primitives. Exercises the codegen for `z.union([...])`.
+Attribute:
+ model: alias
+ type:
+ type: union
+ variants:
+ - string
+ - int
+ - boolean
+
+# Discriminated union — the centerpiece of this fixture. The discriminant is
+# `kind` and the two variants share several fields by hand to mirror the
+# canonical "polymorphic record" pattern users hit in practice. Lives as an
+# alias because document models must be top-level objects.
+ProductDetails:
+ model: alias
+ type:
+ type: union
+ discriminant: kind
+ variants:
+ - type: object
+ fields:
+ kind:
+ type:
+ type: literal
+ value: physical
+ weight_grams:
+ type: int
+ inventory:
+ type:
+ type: list
+ elementType: InventoryEntry
+ is_featured:
+ type:
+ type: literal
+ value: true
+ optional: true
+ - type: object
+ fields:
+ kind:
+ type:
+ type: literal
+ value: digital
+ download_url:
+ type: string
+ file_size_bytes:
+ type: int
+
+Product:
+ model: document
+ path: products/{productId}
+ docs: A product in the store catalog.
+ type:
+ type: object
+ fields:
+ id:
+ type: ProductId
+ name:
+ type: string
+ price:
+ type: Money
+ rating:
+ type: Rating
+ optional: true
+ tags:
+ type:
+ type: list
+ elementType: Tag
+ attributes:
+ type:
+ type: map
+ valueType: Attribute
+ details:
+ type: ProductDetails
+ created_at:
+ type: timestamp
+
+# A document with `additionalFields: true` so we exercise the v3
+# `.passthrough()` and v4 `z.looseObject(...)` codepaths under runtime data.
+RawEvent:
+ model: document
+ path: raw_events/{eventId}
+ type:
+ type: object
+ additionalFields: true
+ fields:
+ type:
+ type: string
+ occurred_at:
+ type: timestamp
diff --git a/tests/integration/zod/generated/v3/catalog.ts b/tests/integration/zod/generated/v3/catalog.ts
new file mode 100644
index 0000000..ccf0f2d
--- /dev/null
+++ b/tests/integration/zod/generated/v3/catalog.ts
@@ -0,0 +1,69 @@
+import * as firestore from 'firebase-admin/firestore';
+import { z } from 'zod-v3';
+
+export const AttributeSchema = z.union([z.string(), z.number().int(), z.boolean()]);
+export type Attribute = z.infer;
+
+/** ISO-4217 currency code. */
+export const CurrencySchema = z.enum(['USD', 'EUR', 'GBP']).describe('ISO-4217 currency code.');
+export type Currency = z.infer;
+
+export const InventoryEntrySchema = z.object({ sku: z.string(), quantity: z.number().int() }).strict();
+export type InventoryEntry = z.infer;
+
+/** A monetary amount expressed in a given currency. */
+export const MoneySchema = z
+ .object({
+ amount_minor: z.number().int().describe("Amount in the currency's minor unit (e.g. cents for USD)."),
+ currency: z.lazy(() => CurrencySchema),
+ })
+ .strict()
+ .describe('A monetary amount expressed in a given currency.');
+export type Money = z.infer;
+
+export const ProductDetailsSchema = z.discriminatedUnion('kind', [
+ z
+ .object({
+ kind: z.literal('physical'),
+ weight_grams: z.number().int(),
+ inventory: z.array(z.lazy(() => InventoryEntrySchema)),
+ is_featured: z.literal(true).optional(),
+ })
+ .strict(),
+ z.object({ kind: z.literal('digital'), download_url: z.string(), file_size_bytes: z.number().int() }).strict(),
+]);
+export type ProductDetails = z.infer;
+
+/** Stable identifier for a catalog product. */
+export const ProductIdSchema = z.string().describe('Stable identifier for a catalog product.');
+export type ProductId = z.infer;
+
+/** Customer rating expressed as an integer 1..5. */
+export const RatingSchema = z
+ .union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)])
+ .describe('Customer rating expressed as an integer 1..5.');
+export type Rating = z.infer;
+
+export const TagSchema = z.string();
+export type Tag = z.infer;
+
+/** A product in the store catalog. */
+export const ProductSchema = z
+ .object({
+ id: z.lazy(() => ProductIdSchema),
+ name: z.string(),
+ price: z.lazy(() => MoneySchema),
+ rating: z.lazy(() => RatingSchema).optional(),
+ tags: z.array(z.lazy(() => TagSchema)),
+ attributes: z.record(z.lazy(() => AttributeSchema)),
+ details: z.lazy(() => ProductDetailsSchema),
+ created_at: z.instanceof(firestore.Timestamp),
+ })
+ .strict()
+ .describe('A product in the store catalog.');
+export type Product = z.infer;
+
+export const RawEventSchema = z
+ .object({ type: z.string(), occurred_at: z.instanceof(firestore.Timestamp) })
+ .passthrough();
+export type RawEvent = z.infer;
diff --git a/tests/integration/zod/generated/v3/projects.ts b/tests/integration/zod/generated/v3/projects.ts
new file mode 100644
index 0000000..6796cbd
--- /dev/null
+++ b/tests/integration/zod/generated/v3/projects.ts
@@ -0,0 +1,13 @@
+import * as firestore from 'firebase-admin/firestore';
+import { z } from 'zod-v3';
+
+/** A project document. */
+export const ProjectSchema = z
+ .object({
+ id: z.string().describe('Caller-supplied identifier preserved verbatim in the document body.'),
+ display_name: z.string().describe('Human-readable label for the project.'),
+ created_at: z.instanceof(firestore.Timestamp),
+ })
+ .strict()
+ .describe('A project document.');
+export type Project = z.infer;
diff --git a/tests/integration/zod/generated/v3/secrets.ts b/tests/integration/zod/generated/v3/secrets.ts
new file mode 100644
index 0000000..669b1f3
--- /dev/null
+++ b/tests/integration/zod/generated/v3/secrets.ts
@@ -0,0 +1,17 @@
+import * as firestore from 'firebase-admin/firestore';
+import { z } from 'zod-v3';
+
+/** A document storing opaque binary material alongside metadata. */
+export const SecretSchema = z
+ .object({
+ label: z.string().describe('Human-readable label for the secret.'),
+ payload: z.instanceof(Buffer).describe('Opaque binary blob (encrypted material, key bytes, etc.).'),
+ checksum: z.instanceof(Buffer).describe('A second bytes field, e.g. a SHA-256 digest of the payload.'),
+ shards: z
+ .array(z.instanceof(Buffer))
+ .describe('Additional bytes blobs stored as a list to exercise bytes-in-list.'),
+ created_at: z.instanceof(firestore.Timestamp),
+ })
+ .strict()
+ .describe('A document storing opaque binary material alongside metadata.');
+export type Secret = z.infer;
diff --git a/tests/integration/zod/generated/v3/users.ts b/tests/integration/zod/generated/v3/users.ts
new file mode 100644
index 0000000..b1a27ff
--- /dev/null
+++ b/tests/integration/zod/generated/v3/users.ts
@@ -0,0 +1,19 @@
+import * as firestore from 'firebase-admin/firestore';
+import { z } from 'zod-v3';
+
+/** Represents a user's role within a project. */
+export const UserRoleSchema = z
+ .enum(['owner', 'admin', 'member'])
+ .describe("Represents a user's role within a project.");
+export type UserRole = z.infer;
+
+/** Represents a user that belongs to a project. */
+export const UserSchema = z
+ .object({
+ username: z.string().describe('A string that uniquely identifies the user within a project.'),
+ role: z.lazy(() => UserRoleSchema),
+ created_at: z.instanceof(firestore.Timestamp),
+ })
+ .strict()
+ .describe('Represents a user that belongs to a project.');
+export type User = z.infer;
diff --git a/tests/integration/zod/generated/v4-web/secrets.ts b/tests/integration/zod/generated/v4-web/secrets.ts
new file mode 100644
index 0000000..07034c3
--- /dev/null
+++ b/tests/integration/zod/generated/v4-web/secrets.ts
@@ -0,0 +1,20 @@
+import * as firestore from 'firebase/firestore';
+import { z } from 'zod-v4';
+
+/** A document storing opaque binary material alongside metadata. */
+export const SecretSchema = z
+ .strictObject({
+ label: z.string().describe('Human-readable label for the secret.'),
+ payload: z
+ .instanceof(firestore.Bytes as unknown as new (...args: never[]) => firestore.Bytes)
+ .describe('Opaque binary blob (encrypted material, key bytes, etc.).'),
+ checksum: z
+ .instanceof(firestore.Bytes as unknown as new (...args: never[]) => firestore.Bytes)
+ .describe('A second bytes field, e.g. a SHA-256 digest of the payload.'),
+ shards: z
+ .array(z.instanceof(firestore.Bytes as unknown as new (...args: never[]) => firestore.Bytes))
+ .describe('Additional bytes blobs stored as a list to exercise bytes-in-list.'),
+ created_at: z.instanceof(firestore.Timestamp),
+ })
+ .describe('A document storing opaque binary material alongside metadata.');
+export type Secret = z.infer;
diff --git a/tests/integration/zod/generated/v4/catalog.ts b/tests/integration/zod/generated/v4/catalog.ts
new file mode 100644
index 0000000..8b6119c
--- /dev/null
+++ b/tests/integration/zod/generated/v4/catalog.ts
@@ -0,0 +1,66 @@
+import * as firestore from 'firebase-admin/firestore';
+import { z } from 'zod-v4';
+
+export const AttributeSchema = z.union([z.string(), z.number().int(), z.boolean()]);
+export type Attribute = z.infer;
+
+/** ISO-4217 currency code. */
+export const CurrencySchema = z.enum(['USD', 'EUR', 'GBP']).describe('ISO-4217 currency code.');
+export type Currency = z.infer;
+
+export const InventoryEntrySchema = z.strictObject({ sku: z.string(), quantity: z.number().int() });
+export type InventoryEntry = z.infer;
+
+/** A monetary amount expressed in a given currency. */
+export const MoneySchema = z
+ .strictObject({
+ amount_minor: z.number().int().describe("Amount in the currency's minor unit (e.g. cents for USD)."),
+ currency: z.lazy(() => CurrencySchema),
+ })
+ .describe('A monetary amount expressed in a given currency.');
+export type Money = z.infer;
+
+export const ProductDetailsSchema = z.discriminatedUnion('kind', [
+ z.strictObject({
+ kind: z.literal('physical'),
+ weight_grams: z.number().int(),
+ inventory: z.array(z.lazy(() => InventoryEntrySchema)),
+ is_featured: z.literal(true).optional(),
+ }),
+ z.strictObject({ kind: z.literal('digital'), download_url: z.string(), file_size_bytes: z.number().int() }),
+]);
+export type ProductDetails = z.infer;
+
+/** Stable identifier for a catalog product. */
+export const ProductIdSchema = z.string().describe('Stable identifier for a catalog product.');
+export type ProductId = z.infer;
+
+/** Customer rating expressed as an integer 1..5. */
+export const RatingSchema = z
+ .union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)])
+ .describe('Customer rating expressed as an integer 1..5.');
+export type Rating = z.infer;
+
+export const TagSchema = z.string();
+export type Tag = z.infer;
+
+/** A product in the store catalog. */
+export const ProductSchema = z
+ .strictObject({
+ id: z.lazy(() => ProductIdSchema),
+ name: z.string(),
+ price: z.lazy(() => MoneySchema),
+ rating: z.lazy(() => RatingSchema).optional(),
+ tags: z.array(z.lazy(() => TagSchema)),
+ attributes: z.record(
+ z.string(),
+ z.lazy(() => AttributeSchema)
+ ),
+ details: z.lazy(() => ProductDetailsSchema),
+ created_at: z.instanceof(firestore.Timestamp),
+ })
+ .describe('A product in the store catalog.');
+export type Product = z.infer;
+
+export const RawEventSchema = z.looseObject({ type: z.string(), occurred_at: z.instanceof(firestore.Timestamp) });
+export type RawEvent = z.infer;
diff --git a/tests/integration/zod/generated/v4/projects.ts b/tests/integration/zod/generated/v4/projects.ts
new file mode 100644
index 0000000..b614ac2
--- /dev/null
+++ b/tests/integration/zod/generated/v4/projects.ts
@@ -0,0 +1,12 @@
+import * as firestore from 'firebase-admin/firestore';
+import { z } from 'zod-v4';
+
+/** A project document. */
+export const ProjectSchema = z
+ .strictObject({
+ id: z.string().describe('Caller-supplied identifier preserved verbatim in the document body.'),
+ display_name: z.string().describe('Human-readable label for the project.'),
+ created_at: z.instanceof(firestore.Timestamp),
+ })
+ .describe('A project document.');
+export type Project = z.infer;
diff --git a/tests/integration/zod/generated/v4/secrets.ts b/tests/integration/zod/generated/v4/secrets.ts
new file mode 100644
index 0000000..8b8e6d5
--- /dev/null
+++ b/tests/integration/zod/generated/v4/secrets.ts
@@ -0,0 +1,16 @@
+import * as firestore from 'firebase-admin/firestore';
+import { z } from 'zod-v4';
+
+/** A document storing opaque binary material alongside metadata. */
+export const SecretSchema = z
+ .strictObject({
+ label: z.string().describe('Human-readable label for the secret.'),
+ payload: z.instanceof(Buffer).describe('Opaque binary blob (encrypted material, key bytes, etc.).'),
+ checksum: z.instanceof(Buffer).describe('A second bytes field, e.g. a SHA-256 digest of the payload.'),
+ shards: z
+ .array(z.instanceof(Buffer))
+ .describe('Additional bytes blobs stored as a list to exercise bytes-in-list.'),
+ created_at: z.instanceof(firestore.Timestamp),
+ })
+ .describe('A document storing opaque binary material alongside metadata.');
+export type Secret = z.infer;
diff --git a/tests/integration/zod/generated/v4/users.ts b/tests/integration/zod/generated/v4/users.ts
new file mode 100644
index 0000000..579c845
--- /dev/null
+++ b/tests/integration/zod/generated/v4/users.ts
@@ -0,0 +1,18 @@
+import * as firestore from 'firebase-admin/firestore';
+import { z } from 'zod-v4';
+
+/** Represents a user's role within a project. */
+export const UserRoleSchema = z
+ .enum(['owner', 'admin', 'member'])
+ .describe("Represents a user's role within a project.");
+export type UserRole = z.infer;
+
+/** Represents a user that belongs to a project. */
+export const UserSchema = z
+ .strictObject({
+ username: z.string().describe('A string that uniquely identifies the user within a project.'),
+ role: z.lazy(() => UserRoleSchema),
+ created_at: z.instanceof(firestore.Timestamp),
+ })
+ .describe('Represents a user that belongs to a project.');
+export type User = z.infer;
diff --git a/tests/integration/zod/tests/_helpers.ts b/tests/integration/zod/tests/_helpers.ts
new file mode 100644
index 0000000..c955df9
--- /dev/null
+++ b/tests/integration/zod/tests/_helpers.ts
@@ -0,0 +1,42 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+const SHARED_FIXTURES_ROOT = resolve(__dirname, '../../_fixtures');
+const ZOD_FIXTURES_ROOT = resolve(__dirname, '../_fixtures');
+
+/**
+ * Loads a JSON fixture by scenario + name. Looks under the Zod-only fixtures
+ * dir first, then falls back to the shared fixtures dir. The fallback lets
+ * test files reference shared scenarios (`users`, `secrets`, `projects`)
+ * the same way as zod-only ones (`catalog`).
+ */
+export function loadSample(scenario: string, name: string): unknown {
+ for (const root of [ZOD_FIXTURES_ROOT, SHARED_FIXTURES_ROOT]) {
+ const candidate = resolve(root, 'samples', scenario, `${name}.json`);
+ try {
+ return JSON.parse(readFileSync(candidate, 'utf8'));
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
+ }
+ }
+ throw new Error(`Could not find sample '${scenario}/${name}.json' under either Zod-only or shared fixtures.`);
+}
+
+export function ensureEmulatorEnv(): { host: string; port: number } {
+ const raw = process.env.FIRESTORE_EMULATOR_HOST;
+ if (!raw) {
+ throw new Error('FIRESTORE_EMULATOR_HOST is not set. Run via `yarn test:integration:zod`.');
+ }
+ const lastColon = raw.lastIndexOf(':');
+ if (lastColon < 0) {
+ throw new Error(`FIRESTORE_EMULATOR_HOST=${raw} is not in host:port form`);
+ }
+ const host = raw.slice(0, lastColon);
+ const port = Number(raw.slice(lastColon + 1));
+ if (!Number.isFinite(port) || port <= 0) {
+ throw new Error(`FIRESTORE_EMULATOR_HOST=${raw} has an invalid port`);
+ }
+ process.env.GOOGLE_CLOUD_PROJECT ??= 'demo-integration';
+ process.env.GCLOUD_PROJECT ??= process.env.GOOGLE_CLOUD_PROJECT;
+ return { host, port };
+}
diff --git a/tests/integration/zod/tests/catalog.parsing.test.ts b/tests/integration/zod/tests/catalog.parsing.test.ts
new file mode 100644
index 0000000..38c8c3b
--- /dev/null
+++ b/tests/integration/zod/tests/catalog.parsing.test.ts
@@ -0,0 +1,231 @@
+import { Timestamp } from 'firebase-admin/firestore';
+import { describe, expect, it } from 'vitest';
+
+import * as v3 from '../generated/v3/catalog.js';
+import * as v4 from '../generated/v4/catalog.js';
+import { loadSample } from './_helpers.js';
+
+// Pure-Zod parsing tests for the complex `catalog` fixture. These do NOT
+// touch the Firestore emulator — they exercise the generated schemas
+// directly against hand-crafted wire-shape inputs. The fixture covers:
+//
+// - string + int enums (`Currency`, `Rating`)
+// - alias references (`ProductId`, `Money`, `Tag`, `InventoryEntry`)
+// - simple union (`Attribute`)
+// - discriminated union (`ProductDetails` over `kind`)
+// - lists of primitives, lists of aliases, lists of objects
+// - records (maps)
+// - optional fields (`rating`, `is_featured`)
+// - top-level `additionalFields: true` (`RawEvent`)
+//
+// `firebase-admin/firestore`'s `Timestamp` constructor is pure JS — we can
+// build instances without booting the SDK or talking to the emulator, which
+// keeps these tests self-contained.
+
+type Variant = 'v3' | 'v4';
+
+const VARIANTS: Array<{ name: Variant; mod: typeof v3 | typeof v4 }> = [
+ { name: 'v3', mod: v3 },
+ { name: 'v4', mod: v4 },
+];
+
+const ts = new Timestamp(1_700_000_000, 0);
+
+interface CatalogSample {
+ id: string;
+ name: string;
+ price: { amount_minor: number; currency: string };
+ rating?: number;
+ tags: string[];
+ attributes: Record;
+ details: Record;
+ created_at: string;
+}
+
+function withTimestamp(sample: CatalogSample): unknown {
+ return { ...sample, created_at: ts };
+}
+
+describe.each(VARIANTS)('catalog ($name)', ({ mod }) => {
+ describe('Currency (string enum)', () => {
+ it('accepts every member', () => {
+ for (const c of ['USD', 'EUR', 'GBP'] as const) {
+ expect(mod.CurrencySchema.safeParse(c).success).toBe(true);
+ }
+ });
+ it('rejects unknown codes and non-strings', () => {
+ expect(mod.CurrencySchema.safeParse('JPY').success).toBe(false);
+ expect(mod.CurrencySchema.safeParse('').success).toBe(false);
+ expect(mod.CurrencySchema.safeParse(1).success).toBe(false);
+ expect(mod.CurrencySchema.safeParse(null).success).toBe(false);
+ });
+ });
+
+ describe('Rating (int enum)', () => {
+ it('accepts every numeric member', () => {
+ for (const r of [1, 2, 3, 4, 5]) {
+ expect(mod.RatingSchema.safeParse(r).success).toBe(true);
+ }
+ });
+ it('rejects out-of-range and non-integer values', () => {
+ expect(mod.RatingSchema.safeParse(0).success).toBe(false);
+ expect(mod.RatingSchema.safeParse(6).success).toBe(false);
+ expect(mod.RatingSchema.safeParse(3.5).success).toBe(false);
+ expect(mod.RatingSchema.safeParse('5').success).toBe(false);
+ });
+ });
+
+ describe('Attribute (simple union)', () => {
+ it('accepts every member type', () => {
+ expect(mod.AttributeSchema.safeParse('graphite').success).toBe(true);
+ expect(mod.AttributeSchema.safeParse(42).success).toBe(true);
+ expect(mod.AttributeSchema.safeParse(true).success).toBe(true);
+ });
+ it('rejects values outside the union', () => {
+ expect(mod.AttributeSchema.safeParse(null).success).toBe(false);
+ expect(mod.AttributeSchema.safeParse({ x: 1 }).success).toBe(false);
+ expect(mod.AttributeSchema.safeParse([1, 2]).success).toBe(false);
+ // Doubles must be rejected: `Attribute` covers `string | int | boolean`,
+ // and our `int` codegen tightens to `z.number().int()`.
+ expect(mod.AttributeSchema.safeParse(3.14).success).toBe(false);
+ });
+ });
+
+ describe('Money (alias-referenced object)', () => {
+ it('accepts a well-formed money value', () => {
+ expect(mod.MoneySchema.safeParse({ amount_minor: 4999, currency: 'USD' }).success).toBe(true);
+ });
+ it('rejects the wrong currency', () => {
+ expect(mod.MoneySchema.safeParse({ amount_minor: 1, currency: 'JPY' }).success).toBe(false);
+ });
+ it('rejects missing required fields', () => {
+ expect(mod.MoneySchema.safeParse({ currency: 'USD' }).success).toBe(false);
+ expect(mod.MoneySchema.safeParse({ amount_minor: 1 }).success).toBe(false);
+ });
+ it('rejects extra fields under strict object semantics', () => {
+ expect(mod.MoneySchema.safeParse({ amount_minor: 1, currency: 'USD', extra: 'x' }).success).toBe(false);
+ });
+ });
+
+ describe('ProductDetails (discriminated union)', () => {
+ it('routes to the physical variant on kind=physical', () => {
+ const ok = mod.ProductDetailsSchema.safeParse({
+ kind: 'physical',
+ weight_grams: 1500,
+ inventory: [{ sku: 'A', quantity: 1 }],
+ });
+ expect(ok.success).toBe(true);
+ });
+ it('routes to the digital variant on kind=digital', () => {
+ const ok = mod.ProductDetailsSchema.safeParse({
+ kind: 'digital',
+ download_url: 'https://example.com/x',
+ file_size_bytes: 1024,
+ });
+ expect(ok.success).toBe(true);
+ });
+ it('rejects an unknown discriminator', () => {
+ const bad = mod.ProductDetailsSchema.safeParse({
+ kind: 'subscription',
+ plan: 'pro',
+ });
+ expect(bad.success).toBe(false);
+ });
+ it('rejects fields belonging to the *other* variant', () => {
+ const bad = mod.ProductDetailsSchema.safeParse({
+ kind: 'physical',
+ weight_grams: 1,
+ inventory: [],
+ // download_url is digital-only — strict object semantics reject it
+ download_url: 'https://example.com',
+ });
+ expect(bad.success).toBe(false);
+ });
+ it('treats `is_featured` as optional and only accepts its single literal value when present', () => {
+ const okPresent = mod.ProductDetailsSchema.safeParse({
+ kind: 'physical',
+ weight_grams: 1,
+ inventory: [],
+ is_featured: true,
+ });
+ const okAbsent = mod.ProductDetailsSchema.safeParse({
+ kind: 'physical',
+ weight_grams: 1,
+ inventory: [],
+ });
+ const badValue = mod.ProductDetailsSchema.safeParse({
+ kind: 'physical',
+ weight_grams: 1,
+ inventory: [],
+ is_featured: false,
+ });
+ expect(okPresent.success).toBe(true);
+ expect(okAbsent.success).toBe(true);
+ expect(badValue.success).toBe(false);
+ });
+ });
+
+ describe('Product (top-level document)', () => {
+ it('accepts the physical hand-written sample', () => {
+ const sample = loadSample('catalog', 'widget-physical') as CatalogSample;
+ const result = mod.ProductSchema.safeParse(withTimestamp(sample));
+ expect(result.success).toBe(true);
+ });
+ it('accepts the digital hand-written sample', () => {
+ const sample = loadSample('catalog', 'ebook-digital') as CatalogSample;
+ const result = mod.ProductSchema.safeParse(withTimestamp(sample));
+ expect(result.success).toBe(true);
+ });
+ it('rejects a sample with the wrong details.kind', () => {
+ const sample = loadSample('catalog', 'widget-physical') as CatalogSample;
+ const broken = withTimestamp({
+ ...sample,
+ details: { ...sample.details, kind: 'subscription' },
+ });
+ expect(mod.ProductSchema.safeParse(broken).success).toBe(false);
+ });
+ it('rejects a sample whose tags array contains a non-string', () => {
+ const sample = loadSample('catalog', 'widget-physical') as CatalogSample;
+ const broken = withTimestamp({
+ ...sample,
+ tags: [...sample.tags, 42 as unknown as string],
+ });
+ expect(mod.ProductSchema.safeParse(broken).success).toBe(false);
+ });
+ it('rejects a sample whose attributes record contains a disallowed value type', () => {
+ const sample = loadSample('catalog', 'widget-physical') as CatalogSample;
+ const broken = withTimestamp({
+ ...sample,
+ attributes: { ...sample.attributes, malformed: { nested: 'object' } as unknown as string },
+ });
+ expect(mod.ProductSchema.safeParse(broken).success).toBe(false);
+ });
+ it('rejects a sample whose Timestamp slot is a plain object rather than a Timestamp instance', () => {
+ const sample = loadSample('catalog', 'widget-physical') as CatalogSample;
+ const broken = { ...sample, created_at: { seconds: 0, nanoseconds: 0 } };
+ expect(mod.ProductSchema.safeParse(broken).success).toBe(false);
+ });
+ });
+
+ describe('RawEvent (loose / passthrough object)', () => {
+ it('accepts a document with extra unknown keys when the declared keys are valid', () => {
+ const result = mod.RawEventSchema.safeParse({
+ type: 'click',
+ occurred_at: ts,
+ page: '/home',
+ button: 'cta',
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect((result.data as Record).page).toBe('/home');
+ expect((result.data as Record).button).toBe('cta');
+ }
+ });
+ it('still rejects a document missing a required declared key', () => {
+ expect(mod.RawEventSchema.safeParse({ occurred_at: ts, foo: 'bar' }).success).toBe(false);
+ });
+ it('still rejects a document with the wrong type for a declared key', () => {
+ expect(mod.RawEventSchema.safeParse({ type: 42, occurred_at: ts }).success).toBe(false);
+ });
+ });
+});
diff --git a/tests/integration/zod/tests/secrets.admin.test.ts b/tests/integration/zod/tests/secrets.admin.test.ts
new file mode 100644
index 0000000..6b99374
--- /dev/null
+++ b/tests/integration/zod/tests/secrets.admin.test.ts
@@ -0,0 +1,57 @@
+import { type App, initializeApp } from 'firebase-admin/app';
+import { type Firestore, Timestamp, getFirestore } from 'firebase-admin/firestore';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { SecretSchema } from '../generated/v4/secrets.js';
+import { ensureEmulatorEnv, loadSample } from './_helpers.js';
+
+interface SecretSample {
+ label: string;
+ payload_base64: string;
+ checksum_base64: string;
+ shards_base64: string[];
+ created_at: string;
+}
+
+describe('secrets firebase-admin@13 round-trip (v4 zod schema)', () => {
+ let app: App;
+ let firestore: Firestore;
+
+ beforeAll(() => {
+ ensureEmulatorEnv();
+ app = initializeApp({ projectId: process.env.GOOGLE_CLOUD_PROJECT }, 'zod-secrets-admin-app');
+ firestore = getFirestore(app);
+ });
+
+ afterAll(async () => {
+ await firestore.terminate();
+ });
+
+ it("validates a Secret read from the emulator with the admin SDK's Buffer-shaped bytes", async () => {
+ const sample = loadSample('secrets', 'api-key') as SecretSample;
+ const secretIn = {
+ label: sample.label,
+ payload: Buffer.from(sample.payload_base64, 'base64'),
+ checksum: Buffer.from(sample.checksum_base64, 'base64'),
+ shards: sample.shards_base64.map(s => Buffer.from(s, 'base64')),
+ created_at: Timestamp.fromDate(new Date(sample.created_at)),
+ };
+
+ const collection = firestore.collection(`test_${crypto.randomUUID().replaceAll('-', '')}`);
+ const docRef = collection.doc(crypto.randomUUID());
+ await docRef.set(secretIn);
+
+ const snapshot = await docRef.get();
+ expect(snapshot.exists).toBe(true);
+
+ const parsed = SecretSchema.safeParse(snapshot.data());
+ expect(parsed.success).toBe(true);
+ if (parsed.success) {
+ expect(parsed.data.label).toBe(secretIn.label);
+ // The admin SDK returns bytes as Uint8Array (Buffer subclass).
+ expect(parsed.data.payload).toBeInstanceOf(Uint8Array);
+ expect(parsed.data.shards).toHaveLength(secretIn.shards.length);
+ expect(parsed.data.created_at.toMillis()).toBe(secretIn.created_at.toMillis());
+ }
+ });
+});
diff --git a/tests/integration/zod/tests/secrets.parsing.test.ts b/tests/integration/zod/tests/secrets.parsing.test.ts
new file mode 100644
index 0000000..4d08bc6
--- /dev/null
+++ b/tests/integration/zod/tests/secrets.parsing.test.ts
@@ -0,0 +1,56 @@
+import { Timestamp } from 'firebase-admin/firestore';
+import { describe, expect, it } from 'vitest';
+
+import * as v3Admin from '../generated/v3/secrets.js';
+import * as v4Web from '../generated/v4-web/secrets.js';
+import * as v4Admin from '../generated/v4/secrets.js';
+
+// Pure-Zod parsing tests for the `secrets` fixture (which exercises
+// the `bytes` primitive). The bytes representation differs per target:
+//
+// * firebase-admin: Node `Buffer` (z.instanceof(Buffer))
+// * firebase web SDK: firestore.Bytes (z.instanceof(firestore.Bytes))
+//
+// We exercise all three combinations the orchestrator emits today:
+// v3 admin, v4 admin, v4 web.
+
+const ts = new Timestamp(1_700_000_000, 0);
+
+const adminInput = () => ({
+ label: 'API key',
+ payload: Buffer.from([0x01, 0x02, 0x03]),
+ checksum: Buffer.from(new Uint8Array(32)),
+ shards: [Buffer.from([0xff]), Buffer.from([0xfe])],
+ created_at: ts,
+});
+
+describe.each([
+ { name: 'v3 admin', mod: v3Admin },
+ { name: 'v4 admin', mod: v4Admin },
+])('secrets admin ($name)', ({ mod }) => {
+ it('accepts a Secret built from Buffer values', () => {
+ expect(mod.SecretSchema.safeParse(adminInput()).success).toBe(true);
+ });
+ it('rejects a Secret whose payload is a base64 string instead of bytes', () => {
+ expect(mod.SecretSchema.safeParse({ ...adminInput(), payload: 'AQID' }).success).toBe(false);
+ });
+ it('rejects a Secret whose `shards` list contains a non-Buffer entry', () => {
+ expect(mod.SecretSchema.safeParse({ ...adminInput(), shards: [Buffer.from([1]), 'not bytes'] }).success).toBe(
+ false
+ );
+ });
+ it('accepts an empty `shards` list', () => {
+ expect(mod.SecretSchema.safeParse({ ...adminInput(), shards: [] }).success).toBe(true);
+ });
+});
+
+describe('secrets web (v4)', () => {
+ it('rejects Buffer-typed bytes against the firestore.Bytes-bound schema', () => {
+ // The web schema expects firestore.Bytes (an entirely different class
+ // from Buffer), so passing a Buffer should fail at parse time. We don't
+ // construct a `firestore.Bytes` instance here because it requires
+ // initializing the Firebase web SDK; the dedicated emulator round-trip
+ // in `secrets.web.test.ts` covers the positive path.
+ expect(v4Web.SecretSchema.safeParse(adminInput()).success).toBe(false);
+ });
+});
diff --git a/tests/integration/zod/tests/secrets.web.test.ts b/tests/integration/zod/tests/secrets.web.test.ts
new file mode 100644
index 0000000..f09211c
--- /dev/null
+++ b/tests/integration/zod/tests/secrets.web.test.ts
@@ -0,0 +1,74 @@
+import { type FirebaseApp, initializeApp as initializeWebApp } from 'firebase/app';
+import {
+ Bytes,
+ type Firestore,
+ Timestamp,
+ connectFirestoreEmulator,
+ doc,
+ getDoc,
+ getFirestore,
+ setDoc,
+ terminate,
+} from 'firebase/firestore';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { SecretSchema } from '../generated/v4-web/secrets.js';
+import { ensureEmulatorEnv, loadSample } from './_helpers.js';
+
+interface SecretSample {
+ label: string;
+ payload_base64: string;
+ checksum_base64: string;
+ shards_base64: string[];
+ created_at: string;
+}
+
+describe('secrets firebase web SDK round-trip (v4 zod schema)', () => {
+ let app: FirebaseApp;
+ let firestore: Firestore;
+
+ beforeAll(() => {
+ const { host, port } = ensureEmulatorEnv();
+ app = initializeWebApp(
+ {
+ apiKey: 'fake-api-key',
+ projectId: process.env.GOOGLE_CLOUD_PROJECT,
+ },
+ 'zod-secrets-web-app'
+ );
+ firestore = getFirestore(app);
+ connectFirestoreEmulator(firestore, host, port);
+ });
+
+ afterAll(async () => {
+ await terminate(firestore);
+ });
+
+ it("validates a Secret read from the emulator with the web SDK's `firestore.Bytes` shape", async () => {
+ const sample = loadSample('secrets', 'api-key') as SecretSample;
+ const secretIn = {
+ label: sample.label,
+ payload: Bytes.fromBase64String(sample.payload_base64),
+ checksum: Bytes.fromBase64String(sample.checksum_base64),
+ shards: sample.shards_base64.map(s => Bytes.fromBase64String(s)),
+ created_at: Timestamp.fromDate(new Date(sample.created_at)),
+ };
+
+ const collection = `test_${crypto.randomUUID().replaceAll('-', '')}`;
+ const docRef = doc(firestore, collection, crypto.randomUUID());
+ await setDoc(docRef, secretIn);
+
+ const snapshot = await getDoc(docRef);
+ expect(snapshot.exists()).toBe(true);
+
+ const parsed = SecretSchema.safeParse(snapshot.data());
+ expect(parsed.success).toBe(true);
+ if (parsed.success) {
+ expect(parsed.data.label).toBe(secretIn.label);
+ expect(parsed.data.payload).toBeInstanceOf(Bytes);
+ expect(parsed.data.payload.isEqual(secretIn.payload)).toBe(true);
+ expect(parsed.data.shards).toHaveLength(secretIn.shards.length);
+ expect(parsed.data.created_at.toMillis()).toBe(secretIn.created_at.toMillis());
+ }
+ });
+});
diff --git a/tests/integration/zod/tests/users.admin.test.ts b/tests/integration/zod/tests/users.admin.test.ts
new file mode 100644
index 0000000..d79eecb
--- /dev/null
+++ b/tests/integration/zod/tests/users.admin.test.ts
@@ -0,0 +1,64 @@
+import { type App, initializeApp } from 'firebase-admin/app';
+import { type Firestore, Timestamp, getFirestore } from 'firebase-admin/firestore';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+// Firestore emulator round-trip: write a typed value, read it back, and
+// validate the wire-shape result with the v4 Zod schema. This proves the
+// schema's `z.instanceof(firestore.Timestamp)` and the strict-object shape
+// match what the SDK actually produces.
+import { UserRoleSchema, UserSchema } from '../generated/v4/users.js';
+import { ensureEmulatorEnv } from './_helpers.js';
+
+describe('users firebase-admin@13 round-trip (v4 zod schema)', () => {
+ let app: App;
+ let firestore: Firestore;
+
+ beforeAll(() => {
+ ensureEmulatorEnv();
+ app = initializeApp({ projectId: process.env.GOOGLE_CLOUD_PROJECT }, 'zod-users-admin-app');
+ firestore = getFirestore(app);
+ });
+
+ afterAll(async () => {
+ await firestore.terminate();
+ });
+
+ it('round-trips a User document and validates it with UserSchema', async () => {
+ const userIn = {
+ username: 'alice',
+ role: UserRoleSchema.parse('admin'),
+ created_at: new Timestamp(1_700_000_000, 0),
+ };
+
+ const collection = firestore.collection(`test_${crypto.randomUUID().replaceAll('-', '')}`);
+ const docRef = collection.doc(crypto.randomUUID());
+ await docRef.set(userIn);
+
+ const snapshot = await docRef.get();
+ expect(snapshot.exists).toBe(true);
+
+ const raw = snapshot.data();
+ const parsed = UserSchema.safeParse(raw);
+ expect(parsed.success).toBe(true);
+ if (parsed.success) {
+ expect(parsed.data.username).toBe(userIn.username);
+ expect(parsed.data.role).toBe(userIn.role);
+ expect(parsed.data.created_at.toMillis()).toBe(userIn.created_at.toMillis());
+ }
+ });
+
+ it('rejects a doc whose `role` was tampered with at the Firestore layer', async () => {
+ const collection = firestore.collection(`test_${crypto.randomUUID().replaceAll('-', '')}`);
+ const docRef = collection.doc(crypto.randomUUID());
+ // Bypass the schema by writing untyped data straight to Firestore.
+ await docRef.set({
+ username: 'mallory',
+ role: 'visitor',
+ created_at: new Timestamp(1_700_000_000, 0),
+ });
+
+ const snapshot = await docRef.get();
+ const parsed = UserSchema.safeParse(snapshot.data());
+ expect(parsed.success).toBe(false);
+ });
+});
diff --git a/tests/integration/zod/tests/users.parsing.test.ts b/tests/integration/zod/tests/users.parsing.test.ts
new file mode 100644
index 0000000..59f0b30
--- /dev/null
+++ b/tests/integration/zod/tests/users.parsing.test.ts
@@ -0,0 +1,66 @@
+import { Timestamp } from 'firebase-admin/firestore';
+import { describe, expect, it } from 'vitest';
+
+import * as v3 from '../generated/v3/users.js';
+import * as v4 from '../generated/v4/users.js';
+
+// Pure-Zod parsing tests for the shared `users` fixture (used by every
+// generator's integration suite). Exercised against both the v3 and v4
+// generated outputs to confirm that variant-specific surface differences
+// (`z.object().strict()` vs `z.strictObject(...)`, `z.record(value)` vs
+// `z.record(z.string(), value)`, …) do not change runtime semantics.
+
+type Variant = 'v3' | 'v4';
+const VARIANTS: Array<{ name: Variant; mod: typeof v3 | typeof v4 }> = [
+ { name: 'v3', mod: v3 },
+ { name: 'v4', mod: v4 },
+];
+
+const ts = new Timestamp(1_700_000_000, 0);
+
+describe.each(VARIANTS)('users ($name)', ({ mod }) => {
+ describe('UserRoleSchema', () => {
+ it('accepts every defined role', () => {
+ for (const role of ['owner', 'admin', 'member'] as const) {
+ expect(mod.UserRoleSchema.safeParse(role).success).toBe(true);
+ }
+ });
+ it('rejects unknown roles and non-strings', () => {
+ expect(mod.UserRoleSchema.safeParse('visitor').success).toBe(false);
+ expect(mod.UserRoleSchema.safeParse('').success).toBe(false);
+ expect(mod.UserRoleSchema.safeParse(0).success).toBe(false);
+ });
+ });
+
+ describe('UserSchema', () => {
+ const baseInput = {
+ username: 'alice',
+ role: 'admin' as const,
+ created_at: ts,
+ };
+
+ it('accepts a well-formed user document', () => {
+ expect(mod.UserSchema.safeParse(baseInput).success).toBe(true);
+ });
+
+ it('rejects when a required field is missing', () => {
+ expect(mod.UserSchema.safeParse({ username: 'alice', role: 'admin' }).success).toBe(false);
+ expect(mod.UserSchema.safeParse({ username: 'alice', created_at: ts }).success).toBe(false);
+ });
+
+ it('rejects an extra field under strict semantics', () => {
+ expect(mod.UserSchema.safeParse({ ...baseInput, extra: 'oops' }).success).toBe(false);
+ });
+
+ it('rejects a non-Timestamp `created_at`', () => {
+ expect(mod.UserSchema.safeParse({ ...baseInput, created_at: '2024-01-01' }).success).toBe(false);
+ expect(mod.UserSchema.safeParse({ ...baseInput, created_at: { seconds: 0, nanoseconds: 0 } }).success).toBe(
+ false
+ );
+ });
+
+ it('rejects a non-string username', () => {
+ expect(mod.UserSchema.safeParse({ ...baseInput, username: 42 }).success).toBe(false);
+ });
+ });
+});
diff --git a/tests/integration/zod/tsconfig.json b/tests/integration/zod/tsconfig.json
new file mode 100644
index 0000000..9ac0cfa
--- /dev/null
+++ b/tests/integration/zod/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "declaration": false,
+ "esModuleInterop": true,
+ "module": "Node16",
+ "moduleResolution": "Node16",
+ "noEmit": true,
+ "noImplicitReturns": true,
+ "noUncheckedIndexedAccess": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "target": "ES2022",
+ "types": ["node", "vitest/globals"]
+ },
+ "include": ["generated/**/*.ts", "tests/**/*.ts", "vitest.config.ts"]
+}
diff --git a/tests/integration/zod/vitest.config.ts b/tests/integration/zod/vitest.config.ts
new file mode 100644
index 0000000..64d0285
--- /dev/null
+++ b/tests/integration/zod/vitest.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ // Anchor `include` to this config file's directory; otherwise it resolves
+ // against the cwd of the runner (the repo root) and accidentally picks up
+ // unrelated tests under `tests/`.
+ root: import.meta.dirname,
+ test: {
+ include: ['tests/**/*.test.ts'],
+ globals: true,
+ coverage: {
+ enabled: false,
+ },
+ },
+});
diff --git a/yarn.lock b/yarn.lock
index 3d5404f..b8861e7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8780,6 +8780,16 @@ zip-stream@^4.1.0:
compress-commons "^4.1.2"
readable-stream "^3.6.0"
+"zod-v3@npm:zod@^3.23.8":
+ version "3.25.76"
+ resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
+ integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==
+
+"zod-v4@npm:zod@^4.3.6":
+ version "4.4.3"
+ resolved "https://registry.yarnpkg.com/zod/-/zod-4.4.3.tgz#b680f172885d18bbebf21a834ea25e55a1bbf356"
+ integrity sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==
+
zod@4.3.6:
version "4.3.6"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a"