diff --git a/.changeset/modern-parrots-search.md b/.changeset/modern-parrots-search.md
new file mode 100644
index 0000000..a4bef1a
--- /dev/null
+++ b/.changeset/modern-parrots-search.md
@@ -0,0 +1,5 @@
+---
+"typesync-cli": minor
+---
+
+Add Firestore `document-reference` schema support, including optional target-model narrowing in generated TypeScript and Zod types, schema validation for referenced models, and integration coverage across TypeScript, Zod, Python, and Swift.
diff --git a/.gitignore b/.gitignore
index 41ff335..6dbe065 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,4 +50,6 @@ tests/integration/swift/Package.resolved
ui-debug.log
database-debug.log
pubsub-debug.log
-### INTEGRATION TESTS END
\ No newline at end of file
+### INTEGRATION TESTS END
+
+.cursor/
\ No newline at end of file
diff --git a/docs/schema/types.mdx b/docs/schema/types.mdx
index 86915a2..5ca64ee 100644
--- a/docs/schema/types.mdx
+++ b/docs/schema/types.mdx
@@ -296,6 +296,114 @@ function isValidExample(data) {
+## `document-reference`
+
+Represents a Firestore [document reference](https://firebase.google.com/docs/reference/js/firestore_.documentreference) — a pointer to another Firestore document. Use this in place of storing a document ID as a `string` when you want the field to round-trip as a native reference under each Firestore SDK.
+
+
+ Firestore only supports storing **document** references in document fields, not collection references — the stored value's path must end on a document id. The type name mirrors the SDK class `DocumentReference` to make this constraint obvious at the schema-definition layer.
+
+
+
+ Without an explicit `model`, the `document-reference` type intentionally does not encode the target document's shape. If you want the generated TypeScript and Zod types to be narrowed to a specific target model, use the parameterized form below.
+
+
+
+
+```yaml definition.yml
+Example:
+ model: alias
+ type: document-reference
+```
+
+```ts models.ts
+export type Example = firestore.DocumentReference;
+```
+
+```swift models.swift
+typealias Example = DocumentReference
+```
+
+```python models.py
+Example = firestore.DocumentReference
+```
+
+```javascript firestore.rules
+function isValidExample(data) {
+ return (data is path);
+}
+```
+
+
+
+### Parameterized form
+
+`document-reference` also accepts an object form with an optional `model` field that names the target document (or alias) model. Typesync validates that the referenced model exists in the same schema and threads the target through to the generated code.
+
+The narrowing only takes effect on platforms whose Firestore SDK class is generic:
+
+- **TypeScript** — `firestore.DocumentReference` is emitted instead of `firestore.DocumentReference`.
+- **Zod** — the inferred type from `z.infer` carries the same narrowed shape. The runtime `instanceof` check is identical for both forms — narrowing is purely at the type level.
+- **Python**, **Swift**, **Security Rules** — these SDK classes are not generic, so the emitted type is identical to the bare form. The schema-level validation that `model` resolves still runs.
+
+
+ Zod self-references (e.g. `NoteLink.next: DocumentReference`) are emitted **without** the generic in Zod to avoid `TS2456 Type alias circularly references itself` on the `z.infer`-derived type. Pure `generate-ts` output is unaffected and emits the narrowed self-reference. Cross-model references narrow on both targets.
+
+
+
+
+```yaml definition.yml
+Author:
+ model: document
+ path: authors/{authorId}
+ type:
+ type: object
+ fields:
+ name: { type: string }
+
+Book:
+ model: document
+ path: books/{bookId}
+ type:
+ type: object
+ fields:
+ title: { type: string }
+ author:
+ type:
+ type: document-reference
+ model: Author
+```
+
+```ts models.ts
+export interface Author {
+ name: string;
+}
+export interface Book {
+ title: string;
+ author: firestore.DocumentReference;
+}
+```
+
+```python models.py
+class Author(TypesyncModel):
+ name: str
+class Book(TypesyncModel):
+ title: str
+ author: firestore.DocumentReference # not narrowed; Python SDK class is not generic
+```
+
+```swift models.swift
+struct Author: Codable {
+ var name: String
+}
+struct Book: Codable {
+ var title: String
+ var author: DocumentReference // not narrowed; iOS SDK class is not generic
+}
+```
+
+
+
## `literal`
Represents a literal type.
diff --git a/schema.local.json b/schema.local.json
index cdeb898..32b3d60 100644
--- a/schema.local.json
+++ b/schema.local.json
@@ -34,52 +34,78 @@
{
"anyOf": [
{
- "type": "string",
- "const": "any",
- "description": "Any type."
- },
- {
- "type": "string",
- "const": "unknown",
- "description": "An unknown type."
- },
- {
- "type": "string",
- "const": "nil",
- "description": "A nil type."
- },
- {
- "type": "string",
- "const": "string",
- "description": "A string type."
- },
- {
- "type": "string",
- "const": "boolean",
- "description": "A boolean type."
- },
- {
- "type": "string",
- "const": "int",
- "description": "An integer type."
- },
- {
- "type": "string",
- "const": "double",
- "description": "A double type."
- },
- {
- "type": "string",
- "const": "timestamp",
- "description": "A timestamp type."
+ "anyOf": [
+ {
+ "type": "string",
+ "const": "any",
+ "description": "Any type."
+ },
+ {
+ "type": "string",
+ "const": "unknown",
+ "description": "An unknown type."
+ },
+ {
+ "type": "string",
+ "const": "nil",
+ "description": "A nil type."
+ },
+ {
+ "type": "string",
+ "const": "string",
+ "description": "A string type."
+ },
+ {
+ "type": "string",
+ "const": "boolean",
+ "description": "A boolean type."
+ },
+ {
+ "type": "string",
+ "const": "int",
+ "description": "An integer type."
+ },
+ {
+ "type": "string",
+ "const": "double",
+ "description": "A double type."
+ },
+ {
+ "type": "string",
+ "const": "timestamp",
+ "description": "A timestamp type."
+ },
+ {
+ "type": "string",
+ "const": "bytes",
+ "description": "A bytes type."
+ },
+ {
+ "type": "string",
+ "const": "document-reference",
+ "description": "A Firestore document reference type. Use this for fields that store a pointer to another Firestore document (e.g. a `DocumentReference` to `users/alice`) rather than the document id as a `string`. Firestore only allows storing document references in fields (not collection references), hence the explicit name."
+ }
+ ],
+ "description": "A primitive type"
},
{
- "type": "string",
- "const": "bytes",
- "description": "A bytes type."
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "document-reference"
+ },
+ "model": {
+ "description": "Name of the target model (alias or document) that the referenced document belongs to. When specified, generators that support generics (TypeScript and Zod) narrow the emitted type to `DocumentReference` instead of `DocumentReference`. Generators without generic `DocumentReference` classes (Python, Swift) ignore this field at the type level but the schema validator still checks that the named model exists.",
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "A Firestore document reference type, parameterized by the target model. Equivalent to the bare `document-reference` string form when `model` is omitted."
}
- ],
- "description": "A primitive type"
+ ]
},
{
"anyOf": [
diff --git a/scripts/integration-test.ts b/scripts/integration-test.ts
index 4fe6809..31f6244 100644
--- a/scripts/integration-test.ts
+++ b/scripts/integration-test.ts
@@ -156,12 +156,15 @@ const PLATFORMS: Record = {
objectTypeFormat: 'interface',
});
},
- // The `bytes` scenario is the only place where the wire-level
- // representation differs across TS targets (Buffer vs firestore.Bytes
- // vs firestore.Blob), so we emit it against the web SDK in addition
- // to the admin SDK. The admin pass above writes `generated/secrets.ts`;
- // this pass writes `generated/web/secrets.ts`, which is imported by
- // the dedicated `secrets.web.test.ts` round-trip suite. The
+ // The `bytes` and `document-reference` scenarios are the only places
+ // where the wire-level representation differs across TS targets
+ // (Buffer vs firestore.Bytes vs firestore.Blob for bytes; and
+ // firebase-admin's vs firebase-web's `DocumentReference` for refs),
+ // so we emit those fixtures against the web SDK in addition to the
+ // admin SDK. The admin pass above writes
+ // `generated/{secrets,references}.ts`; this pass writes
+ // `generated/web/{secrets,references}.ts`, which is imported by the
+ // dedicated `*.web.test.ts` round-trip suites. The
// react-native-firebase target is verified by the unit/snapshot tests
// under `src/renderers/ts/__tests__/`; we don't run it here because
// `@react-native-firebase/firestore` is RN-runtime-only and cannot
@@ -169,7 +172,7 @@ const PLATFORMS: Record = {
extraGenerations: [
{
subdir: 'web',
- onlyFixtures: ['secrets'],
+ onlyFixtures: ['secrets', 'references'],
async generate(definition, outFile) {
await typesync.generateTs({
definition,
@@ -241,7 +244,7 @@ const PLATFORMS: Record = {
},
{
subdir: 'v4-web',
- onlyFixtures: ['secrets'],
+ onlyFixtures: ['secrets', 'references'],
async generate(definition, outFile) {
await typesync.generateZod({
definition,
diff --git a/src/converters/definition-to-schema.ts b/src/converters/definition-to-schema.ts
index 62bf386..b66e89a 100644
--- a/src/converters/definition-to-schema.ts
+++ b/src/converters/definition-to-schema.ts
@@ -22,11 +22,19 @@ export function primitiveTypeToSchema(t: definition.types.Primitive): schema.typ
return { type: 'timestamp' };
case 'bytes':
return { type: 'bytes' };
+ case 'document-reference':
+ return { type: 'document-reference' };
default:
assertNever(t);
}
}
+export function parameterizedDocumentReferenceTypeToSchema(
+ t: definition.types.ParameterizedDocumentReference
+): schema.types.DocumentReference {
+ return t.model !== undefined ? { type: 'document-reference', model: t.model } : { type: 'document-reference' };
+}
+
export function stringLiteralTypeToSchema(t: definition.types.StringLiteral): schema.types.StringLiteral {
return { type: 'string-literal', value: t.value };
}
@@ -145,6 +153,8 @@ export function typeToSchema(t: definition.types.Type): schema.types.Type {
}
switch (t.type) {
+ case 'document-reference':
+ return parameterizedDocumentReferenceTypeToSchema(t);
case 'literal':
return literalTypeToSchema(t);
case 'enum':
diff --git a/src/core/zod/__tests__/build-zod-schema.test.ts b/src/core/zod/__tests__/build-zod-schema.test.ts
index e2cd411..fbc35be 100644
--- a/src/core/zod/__tests__/build-zod-schema.test.ts
+++ b/src/core/zod/__tests__/build-zod-schema.test.ts
@@ -1,4 +1,4 @@
-import { Timestamp } from 'firebase-admin/firestore';
+import { DocumentReference, Timestamp } from 'firebase-admin/firestore';
import { schema } from '../../../schema/index.js';
import { buildZodSchemaMap } from '../build-zod-schema.js';
@@ -19,6 +19,7 @@ describe('buildZodSchemaMap()', () => {
BoolAlias: { model: 'alias', type: 'boolean' },
TimestampAlias: { model: 'alias', type: 'timestamp' },
BytesAlias: { model: 'alias', type: 'bytes' },
+ ReferenceAlias: { model: 'alias', type: 'document-reference' },
NilAlias: { model: 'alias', type: 'nil' },
AnyAlias: { model: 'alias', type: 'any' },
UnknownAlias: { model: 'alias', type: 'unknown' },
@@ -53,6 +54,61 @@ describe('buildZodSchemaMap()', () => {
expect(getSchemaForModel(s, 'BytesAlias').safeParse(new Uint8Array([1, 2, 3])).success).toBe(false);
expect(getSchemaForModel(s, 'BytesAlias').safeParse('hello').success).toBe(false);
});
+
+ it('accepts only firestore DocumentReference instances for reference', () => {
+ // We can't construct a real `DocumentReference` directly (its constructor
+ // is private in the admin typings), so we fake an object whose prototype
+ // chain matches the class — this mirrors how validators see refs that
+ // arrive via the admin SDK at runtime.
+ const fakeRef = Object.create(DocumentReference.prototype) as DocumentReference;
+ expect(getSchemaForModel(s, 'ReferenceAlias').safeParse(fakeRef).success).toBe(true);
+ expect(getSchemaForModel(s, 'ReferenceAlias').safeParse({ path: 'users/abc' }).success).toBe(false);
+ expect(getSchemaForModel(s, 'ReferenceAlias').safeParse('users/abc').success).toBe(false);
+ });
+
+ it('treats the parameterized form identically to the bare form at runtime', () => {
+ // The runtime emitter discards the target-model hint: the validator
+ // only checks `instanceof DocumentReference`. Narrowing happens purely
+ // at the type level via the codegen emitter; the runtime semantics are
+ // the same for both forms.
+ const sParam = schema.createSchemaFromDefinition({
+ Target: {
+ model: 'document',
+ path: 'targets/{targetId}',
+ type: { type: 'object', fields: { name: { type: 'string' } } },
+ },
+ Source: {
+ model: 'document',
+ path: 'sources/{sourceId}',
+ type: {
+ type: 'object',
+ fields: { ref: { type: { type: 'document-reference', model: 'Target' } } },
+ },
+ },
+ });
+ const sourceSchema = getSchemaForModel(sParam, 'Source');
+ const fakeRef = Object.create(DocumentReference.prototype) as DocumentReference;
+ expect(sourceSchema.safeParse({ ref: fakeRef }).success).toBe(true);
+ expect(sourceSchema.safeParse({ ref: 'targets/canonical' }).success).toBe(false);
+ });
+
+ it('rejects a parameterized document-reference whose `model` does not exist in the schema', () => {
+ // Schema-level validation guards us against typos and dangling
+ // references at definition-parse time, before any code generation
+ // runs.
+ expect(() =>
+ schema.createSchemaFromDefinition({
+ Source: {
+ model: 'document',
+ path: 'sources/{sourceId}',
+ type: {
+ type: 'object',
+ fields: { ref: { type: { type: 'document-reference', model: 'Ghost' } } },
+ },
+ },
+ })
+ ).toThrow(/Ghost/);
+ });
});
describe('enums and literals', () => {
diff --git a/src/core/zod/__tests__/codegen-emitter.test.ts b/src/core/zod/__tests__/codegen-emitter.test.ts
index ad968d5..e110649 100644
--- a/src/core/zod/__tests__/codegen-emitter.test.ts
+++ b/src/core/zod/__tests__/codegen-emitter.test.ts
@@ -4,14 +4,18 @@ 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' } = {}
+ overrides: {
+ variant?: 'v3' | 'v4';
+ target?: 'firebase-admin@13' | 'firebase@10' | 'react-native-firebase@21';
+ currentModel?: string;
+ } = {}
) {
const emitter = createCodegenZodEmitter({
variant: overrides.variant ?? 'v4',
target: overrides.target ?? 'firebase-admin@13',
getSchemaIdentifierForModel: name => `${name}Schema`,
});
- return buildZodFromType(type, emitter);
+ return buildZodFromType(type, emitter, { currentModel: overrides.currentModel });
}
describe('createCodegenZodEmitter()', () => {
@@ -80,6 +84,63 @@ describe('createCodegenZodEmitter()', () => {
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)');
});
+
+ it('casts firestore.DocumentReference (private/strict constructor) for every supported target', () => {
+ // `DocumentReference` has a private/strict constructor across every
+ // supported SDK target, so the codegen emits a constructor-shape cast
+ // mirroring the `firestore.Bytes` strategy. The cast is erased at
+ // runtime; the instance check still runs against the real class object.
+ const expected =
+ 'z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)';
+ expect(emit({ type: 'document-reference' }, { target: 'firebase-admin@13' })).toBe(expected);
+ expect(emit({ type: 'document-reference' }, { target: 'firebase@10' })).toBe(expected);
+ expect(emit({ type: 'document-reference' }, { target: 'react-native-firebase@21' })).toBe(expected);
+ });
+
+ it('narrows the cast generic to the target model when document-reference has a `model` and the target differs from the enclosing model', () => {
+ // Parameterized form: `model: Target` → emitter narrows the cast's
+ // generic so `z.infer` carries `DocumentReference`.
+ const narrowed = emit(
+ { type: 'document-reference', model: 'Target' },
+ { target: 'firebase-admin@13', currentModel: 'NoteLink' }
+ );
+ expect(narrowed).toBe(
+ 'z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)'
+ );
+
+ const narrowedWeb = emit(
+ { type: 'document-reference', model: 'Target' },
+ { target: 'firebase@10', currentModel: 'NoteLink' }
+ );
+ expect(narrowedWeb).toBe(
+ 'z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)'
+ );
+ });
+
+ it('suppresses narrowing when the document-reference target equals the enclosing model (self-reference)', () => {
+ // A self-reference (e.g. `NoteLink.next: DocumentReference`)
+ // would otherwise trigger TS2456 ("circularly references itself") on
+ // the `z.infer`-derived type. The codegen falls back to the unnarrowed
+ // cast in that case. The pure `generate-ts` output is unaffected and
+ // emits the narrowed self-reference.
+ const out = emit(
+ { type: 'document-reference', model: 'NoteLink' },
+ { target: 'firebase-admin@13', currentModel: 'NoteLink' }
+ );
+ expect(out).toBe(
+ 'z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)'
+ );
+ });
+
+ it('emits the narrow even when there is no enclosing model (e.g. ad-hoc walks)', () => {
+ const out = emit(
+ { type: 'document-reference', model: 'Foo' },
+ { target: 'firebase-admin@13', currentModel: undefined }
+ );
+ expect(out).toBe(
+ 'z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)'
+ );
+ });
});
describe('records, arrays, and tuples', () => {
diff --git a/src/core/zod/_codegen-emitter.ts b/src/core/zod/_codegen-emitter.ts
index b1febe2..8819023 100644
--- a/src/core/zod/_codegen-emitter.ts
+++ b/src/core/zod/_codegen-emitter.ts
@@ -56,6 +56,18 @@ export function createCodegenZodEmitter(config: ZodCodegenEmitterConfig): ZodEmi
double: () => 'z.number()',
timestamp: () => `z.instanceof(${timestampExpression})`,
bytes: () => `z.instanceof(${bytesExpression})`,
+ documentReference: (targetModel, currentModel) => {
+ // Narrow only when the target is a *different* model from the one
+ // whose schema we're currently emitting. A self-reference (e.g.
+ // `NoteLink.next: DocumentReference`) would otherwise feed
+ // `NoteLink`'s inferred type back into its own schema declaration,
+ // triggering TS2456 ("Type alias circularly references itself") and
+ // TS7022 (implicit `any` on the schema) at compile time. Pure
+ // TypeScript `generate-ts` handles self-references natively via
+ // interface hoisting; Zod's `z.infer` chain cannot.
+ const effectiveModel = targetModel === currentModel ? undefined : targetModel;
+ return `z.instanceof(${expressionForDocumentReferenceInstanceCheck(target, effectiveModel)})`;
+ },
stringLiteral: value => `z.literal(${JSON.stringify(value)})`,
intLiteral: value => `z.literal(${value})`,
@@ -178,3 +190,37 @@ function expressionForBytesInstanceCheck(target: TSGenerationTarget): string {
assertNever(target);
}
}
+
+function expressionForDocumentReferenceInstanceCheck(target: TSGenerationTarget, model: string | undefined): string {
+ // The generic parameter narrows the inferred type from
+ // `z.infer` to `DocumentReference`. The cast
+ // is erased at runtime; the `instanceof` check still runs against the real
+ // class object so any `DocumentReference` instance is accepted regardless
+ // of its (unobservable) generic parameter.
+ const narrowed = model !== undefined ? `firestore.DocumentReference<${model}>` : 'firestore.DocumentReference';
+ switch (target) {
+ case 'firebase-admin@13':
+ case 'firebase-admin@12':
+ case 'firebase-admin@11':
+ case 'firebase-admin@10':
+ // The admin SDK exports `DocumentReference` as a regular class with a
+ // public constructor (technically the constructor is `private` in
+ // newer admin SDK typings too — we cast through `unknown` defensively
+ // so the check compiles across every supported admin major).
+ return `firestore.DocumentReference as unknown as new (...args: never[]) => ${narrowed}`;
+ case 'firebase@11':
+ case 'firebase@10':
+ case 'firebase@9':
+ case 'react-native-firebase@21':
+ case 'react-native-firebase@20':
+ case 'react-native-firebase@19':
+ // Both the modular `firebase/firestore` SDK and React Native Firebase
+ // declare `DocumentReference` with a private constructor, so the same
+ // cast trick used for `firestore.Bytes` is required here. The cast is
+ // erased at runtime; the instance check still runs against the real
+ // class object.
+ return `firestore.DocumentReference as unknown as new (...args: never[]) => ${narrowed}`;
+ default:
+ assertNever(target);
+ }
+}
diff --git a/src/core/zod/_emitter.ts b/src/core/zod/_emitter.ts
index f51c273..236a52f 100644
--- a/src/core/zod/_emitter.ts
+++ b/src/core/zod/_emitter.ts
@@ -24,6 +24,30 @@ export interface ZodEmitter {
double(): TOut;
timestamp(): TOut;
bytes(): TOut;
+ /**
+ * Emits a validator for a Firestore document reference value. The runtime
+ * emitter uses `z.instanceof(DocumentReference)`; the codegen emitter
+ * mirrors that against the active TypeScript Firebase SDK target.
+ *
+ * When `targetModel` is provided (i.e. the schema's `document-reference`
+ * is the parameterized form), the codegen emitter narrows the cast to
+ * `DocumentReference` so the inferred type from
+ * `z.infer` carries the target shape. The runtime emitter
+ * ignores `targetModel` because the `instanceof` check is the same either
+ * way.
+ *
+ * `currentModel` is the name of the model whose Zod schema currently being
+ * built. The codegen emitter uses it to suppress narrowing for
+ * self-references (`targetModel === currentModel`), which would otherwise
+ * trigger TS2456 (`Type alias circularly references itself`) on the
+ * inferred type. `undefined` means "no enclosing model" (e.g. ad-hoc
+ * validation passes); narrowing is always emitted in that case.
+ *
+ * Named to disambiguate from the unrelated `reference(modelName)` below,
+ * which emits a reference to another model (an alias reference) in the
+ * generated schema.
+ */
+ documentReference(targetModel: string | undefined, currentModel: string | undefined): TOut;
stringLiteral(value: string): TOut;
intLiteral(value: number): TOut;
diff --git a/src/core/zod/_runtime-emitter.ts b/src/core/zod/_runtime-emitter.ts
index d778994..d264dba 100644
--- a/src/core/zod/_runtime-emitter.ts
+++ b/src/core/zod/_runtime-emitter.ts
@@ -1,4 +1,4 @@
-import { Timestamp } from 'firebase-admin/firestore';
+import { DocumentReference, Timestamp } from 'firebase-admin/firestore';
import { z } from 'zod';
import type { ZodEmitter } from './_emitter.js';
@@ -28,6 +28,17 @@ export function createRuntimeZodEmitter(registry: RuntimeZodRegistry): ZodEmitte
double: () => z.number(),
timestamp: () => z.instanceof(Timestamp),
bytes: () => z.instanceof(Buffer),
+ // `DocumentReference` declares a private constructor in the admin SDK's
+ // typings, which violates the `new (...args: any[]) => any` constraint
+ // `z.instanceof` enforces. The cast is erased at runtime; the instance
+ // check still runs against the real class object.
+ // `targetModel` / `currentModel` are irrelevant at runtime: the
+ // validator only needs to confirm the value is a `DocumentReference`
+ // instance. Narrowing to a target model happens purely at the type
+ // level via the codegen emitter's generic cast; the runtime emitter
+ // discards both hints.
+ documentReference: (_targetModel, _currentModel) =>
+ z.instanceof(DocumentReference as unknown as new (...args: never[]) => DocumentReference),
stringLiteral: value => z.literal(value),
intLiteral: value => z.literal(value),
diff --git a/src/core/zod/build-zod-schema.ts b/src/core/zod/build-zod-schema.ts
index 8580e13..d126dc9 100644
--- a/src/core/zod/build-zod-schema.ts
+++ b/src/core/zod/build-zod-schema.ts
@@ -5,13 +5,32 @@ import { assertNever } from '../../util/assert.js';
import type { ZodEmitter } from './_emitter.js';
import { type RuntimeZodRegistry, createRuntimeZodEmitter } from './_runtime-emitter.js';
+/**
+ * Optional context threaded through `buildZodFromType` while walking a model's
+ * type tree. Currently only carries the enclosing model name so the
+ * `document-reference` emitter can detect self-references and suppress
+ * narrowing that would otherwise trigger TS2456 on the inferred type.
+ */
+export interface BuildZodFromTypeContext {
+ /**
+ * Name of the model whose Zod schema is currently being built (alias or
+ * document). `undefined` for ad-hoc walks that aren't bound to a specific
+ * model (e.g. driving the runtime emitter directly).
+ */
+ currentModel?: string;
+}
+
/**
* Walks a Typesync schema type and drives the specified emitter to produce an output
* value. This function is the single place in the codebase where schema types are
* translated into Zod-shaped constructs, so both runtime validation and future codegen
* share the exact same rules.
*/
-export function buildZodFromType(type: schema.types.Type, emitter: ZodEmitter): TOut {
+export function buildZodFromType(
+ type: schema.types.Type,
+ emitter: ZodEmitter,
+ context: BuildZodFromTypeContext = {}
+): TOut {
switch (type.type) {
case 'any':
return emitter.any();
@@ -31,6 +50,8 @@ export function buildZodFromType(type: schema.types.Type, emitter: ZodEmit
return emitter.timestamp();
case 'bytes':
return emitter.bytes();
+ case 'document-reference':
+ return emitter.documentReference(type.model, context.currentModel);
case 'string-literal':
return emitter.stringLiteral(type.value);
case 'int-literal':
@@ -42,27 +63,27 @@ export function buildZodFromType(type: schema.types.Type, emitter: ZodEmit
case 'int-enum':
return emitter.intEnum(type.members.map(m => m.value));
case 'tuple':
- return emitter.tuple(type.elements.map(el => buildZodFromType(el, emitter)));
+ return emitter.tuple(type.elements.map(el => buildZodFromType(el, emitter, context)));
case 'list':
- return emitter.array(buildZodFromType(type.elementType, emitter));
+ return emitter.array(buildZodFromType(type.elementType, emitter, context));
case 'map':
- return emitter.record(buildZodFromType(type.valueType, emitter));
+ return emitter.record(buildZodFromType(type.valueType, emitter, context));
case 'object':
return emitter.object(
type.fields.map(field => ({
name: field.name,
- value: buildZodFromType(field.type, emitter),
+ value: buildZodFromType(field.type, emitter, context),
optional: field.optional,
docs: field.docs,
})),
type.additionalFields
);
case 'simple-union':
- return emitter.simpleUnion(type.variants.map(v => buildZodFromType(v, emitter)));
+ return emitter.simpleUnion(type.variants.map(v => buildZodFromType(v, emitter, context)));
case 'discriminated-union':
return emitter.discriminatedUnion(
type.discriminant,
- type.variants.map(v => buildZodFromType(v, emitter))
+ type.variants.map(v => buildZodFromType(v, emitter, context))
);
case 'alias':
return emitter.reference(type.name);
diff --git a/src/definition/_guards.ts b/src/definition/_guards.ts
index be684aa..1e2ae41 100644
--- a/src/definition/_guards.ts
+++ b/src/definition/_guards.ts
@@ -13,6 +13,7 @@ export function isPrimitiveType(candidate: unknown): candidate is types.Primitiv
case 'double':
case 'timestamp':
case 'bytes':
+ case 'document-reference':
return true;
default:
assertNeverNoThrow(c);
@@ -62,3 +63,12 @@ export function isSimpleUnionType(t: types.Type): t is types.SimpleUnion {
export function isAliasType(t: types.Type): t is types.Alias {
return !isPrimitiveType(t) && typeof t === 'string';
}
+
+/**
+ * Recognizes the object form of `document-reference`, i.e.
+ * `{ type: 'document-reference', model?: string }`. The bare string form
+ * `'document-reference'` is recognized by `isPrimitiveType` instead.
+ */
+export function isParameterizedDocumentReferenceType(t: types.Type): t is types.ParameterizedDocumentReference {
+ return typeof t === 'object' && t !== null && (t as { type?: unknown }).type === 'document-reference';
+}
diff --git a/src/definition/impl/_zod-schemas.ts b/src/definition/impl/_zod-schemas.ts
index 71a9eb5..2911f64 100644
--- a/src/definition/impl/_zod-schemas.ts
+++ b/src/definition/impl/_zod-schemas.ts
@@ -20,8 +20,41 @@ export const timestampType = z.literal('timestamp').describe('A timestamp type.'
export const bytesType = z.literal('bytes').describe('A bytes type.');
+export const documentReferenceType = z
+ .literal('document-reference')
+ .describe(
+ 'A Firestore document reference type. Use this for fields that store a pointer to another Firestore document (e.g. a `DocumentReference` to `users/alice`) rather than the document id as a `string`. Firestore only allows storing document references in fields (not collection references), hence the explicit name.'
+ );
+
+export const parameterizedDocumentReferenceType = z
+ .object({
+ type: z.literal('document-reference'),
+ model: z
+ .string()
+ .min(1)
+ .optional()
+ .describe(
+ 'Name of the target model (alias or document) that the referenced document belongs to. When specified, generators that support generics (TypeScript and Zod) narrow the emitted type to `DocumentReference` instead of `DocumentReference`. Generators without generic `DocumentReference` classes (Python, Swift) ignore this field at the type level but the schema validator still checks that the named model exists.'
+ ),
+ })
+ .strict()
+ .describe(
+ 'A Firestore document reference type, parameterized by the target model. Equivalent to the bare `document-reference` string form when `model` is omitted.'
+ );
+
export const primitiveType = z
- .union([anyType, unknownType, nilType, stringType, booleanType, intType, doubleType, timestampType, bytesType])
+ .union([
+ anyType,
+ unknownType,
+ nilType,
+ stringType,
+ booleanType,
+ intType,
+ doubleType,
+ timestampType,
+ bytesType,
+ documentReferenceType,
+ ])
.describe('A primitive type');
export const stringLiteralType = z
@@ -160,6 +193,7 @@ export const aliasType = z.string().describe('An alias type.');
export const type: z.ZodType = z.lazy(() =>
primitiveType
+ .or(parameterizedDocumentReferenceType)
.or(literalType)
.or(enumType)
.or(tupleType)
diff --git a/src/definition/types/_types.ts b/src/definition/types/_types.ts
index 4878dcd..73d8a5c 100644
--- a/src/definition/types/_types.ts
+++ b/src/definition/types/_types.ts
@@ -16,7 +16,39 @@ export type Timestamp = 'timestamp';
export type Bytes = 'bytes';
-export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp | Bytes;
+/**
+ * A Firestore document reference value (string form). Use this when a field
+ * stores a pointer to another document (e.g. `users/alice`) directly, rather
+ * than the document's id as a `string`.
+ *
+ * Firestore only allows storing document references in fields (not collection
+ * references), so the name explicitly mirrors the SDK class `DocumentReference`.
+ *
+ * For the *parameterized* form (which narrows the generated TypeScript type to
+ * a specific target model), see `ParameterizedDocumentReference`.
+ */
+export type DocumentReference = 'document-reference';
+
+export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp | Bytes | DocumentReference;
+
+/**
+ * A Firestore document reference value with an optional `model` field that
+ * names the target document/alias model. When the target model is specified,
+ * generators that support generics (currently TypeScript and Zod) narrow the
+ * emitted type from `DocumentReference` to
+ * `DocumentReference`. Generators whose SDK class is not generic
+ * (Python, Swift) ignore the `model` field at the type level but still
+ * validate that it refers to a defined model.
+ */
+export interface ParameterizedDocumentReference {
+ type: 'document-reference';
+ /**
+ * Name of the target model (alias or document). Must match a model defined
+ * in the same schema. When omitted, behaves identically to the bare
+ * `'document-reference'` string form.
+ */
+ model?: string;
+}
export interface StringLiteral {
type: 'literal';
@@ -117,4 +149,14 @@ export type Union = DiscriminatedUnion | SimpleUnion;
export type Alias = string;
-export type Type = Primitive | Literal | Enum | Tuple | List | Map | Object | Union | Alias;
+export type Type =
+ | Primitive
+ | ParameterizedDocumentReference
+ | Literal
+ | Enum
+ | Tuple
+ | List
+ | Map
+ | Object
+ | Union
+ | Alias;
diff --git a/src/generators/python/__tests__/generator.test.ts b/src/generators/python/__tests__/generator.test.ts
index 7aec1c8..f031278 100644
--- a/src/generators/python/__tests__/generator.test.ts
+++ b/src/generators/python/__tests__/generator.test.ts
@@ -8,7 +8,19 @@ function createGenerator() {
describe('PythonGeneratorImpl', () => {
it('produces an empty generation for an empty schema', () => {
const generation = createGenerator().generate(schema.createSchema());
- expect(generation).toEqual({ type: 'python', declarations: [] });
+ expect(generation).toEqual({ type: 'python', declarations: [], usesDocumentReference: false });
+ });
+
+ it('flags `usesDocumentReference` only when the schema references the Firestore document-reference type', () => {
+ const withoutReference = schema.createSchemaFromDefinition({
+ Plain: { model: 'alias', type: 'string' },
+ });
+ const withReference = schema.createSchemaFromDefinition({
+ OwnerRef: { model: 'alias', type: 'document-reference' },
+ });
+
+ expect(createGenerator().generate(withoutReference).usesDocumentReference).toBe(false);
+ expect(createGenerator().generate(withReference).usesDocumentReference).toBe(true);
});
it('emits an alias declaration for each primitive alias model with the correct Python type', () => {
@@ -22,6 +34,7 @@ describe('PythonGeneratorImpl', () => {
DoubleAlias: { model: 'alias', type: 'double' },
TimestampAlias: { model: 'alias', type: 'timestamp' },
BytesAlias: { model: 'alias', type: 'bytes' },
+ ReferenceAlias: { model: 'alias', type: 'document-reference' },
});
const generation = createGenerator().generate(s);
@@ -36,6 +49,7 @@ describe('PythonGeneratorImpl', () => {
DoubleAlias: { type: 'float' },
TimestampAlias: { type: 'datetime' },
BytesAlias: { type: 'bytes' },
+ ReferenceAlias: { type: 'document-reference' },
};
expect(generation.declarations).toHaveLength(Object.keys(expectedPythonTypeByModelName).length);
diff --git a/src/generators/python/_adjust-schema.ts b/src/generators/python/_adjust-schema.ts
index 042b50b..1cedc1b 100644
--- a/src/generators/python/_adjust-schema.ts
+++ b/src/generators/python/_adjust-schema.ts
@@ -115,6 +115,7 @@ export function adjustSchemaForPython(prevSchema: schema.Schema): schema.python.
case 'double':
case 'timestamp':
case 'bytes':
+ case 'document-reference':
case 'string-literal':
case 'int-literal':
case 'boolean-literal':
diff --git a/src/generators/python/_converters.ts b/src/generators/python/_converters.ts
index 267e491..12370b2 100644
--- a/src/generators/python/_converters.ts
+++ b/src/generators/python/_converters.ts
@@ -38,6 +38,10 @@ export function bytesTypeToPython(_t: schema.python.types.Bytes): python.Bytes {
return { type: 'bytes' };
}
+export function documentReferenceTypeToPython(t: schema.python.types.DocumentReference): python.DocumentReference {
+ return t.model !== undefined ? { type: 'document-reference', model: t.model } : { type: 'document-reference' };
+}
+
export function literalTypeToPython(t: schema.python.types.Literal): python.Literal {
return { type: 'literal', value: t.value };
}
@@ -88,6 +92,8 @@ export function flatTypeToPython(t: schema.python.types.Type): python.Type {
return timestampTypeToPython(t);
case 'bytes':
return bytesTypeToPython(t);
+ case 'document-reference':
+ return documentReferenceTypeToPython(t);
case 'string-literal':
case 'int-literal':
case 'boolean-literal':
diff --git a/src/generators/python/_impl.ts b/src/generators/python/_impl.ts
index 3b83d18..c2927a7 100644
--- a/src/generators/python/_impl.ts
+++ b/src/generators/python/_impl.ts
@@ -28,7 +28,14 @@ class PythonGeneratorImpl implements PythonGenerator {
const d = this.createDeclarationForDocumentModel(model);
declarations.push(d);
});
- return { type: 'python', declarations };
+ // Used by the renderer to decide whether to emit
+ // `from google.cloud import firestore`. We walk the source schema rather
+ // than the adjusted schema so the check is independent of the alias
+ // extraction that happens inside `adjustSchemaForPython`.
+ const usesDocumentReference =
+ s.aliasModels.some(m => schemaTypeUsesDocumentReference(m.type)) ||
+ s.documentModels.some(m => schemaTypeUsesDocumentReference(m.type));
+ return { type: 'python', declarations, usesDocumentReference };
}
private createDeclarationForAliasModel(model: schema.python.AliasModel): PythonDeclaration {
@@ -42,6 +49,7 @@ class PythonGeneratorImpl implements PythonGenerator {
case 'double':
case 'timestamp':
case 'bytes':
+ case 'document-reference':
case 'string-literal':
case 'int-literal':
case 'boolean-literal':
@@ -128,3 +136,47 @@ class PythonGeneratorImpl implements PythonGenerator {
export function createPythonGenerator(config: PythonGeneratorConfig): PythonGenerator {
return new PythonGeneratorImpl(config);
}
+
+/**
+ * Walks a Typesync schema type tree and reports whether any node is the
+ * `document-reference` primitive. Alias references are intentionally not
+ * followed — each alias model's own type is visited separately by the caller,
+ * so any `document-reference` usage is eventually seen at its definition site
+ * regardless of how many alias references point at it.
+ */
+function schemaTypeUsesDocumentReference(type: schema.types.Type): boolean {
+ switch (type.type) {
+ case 'any':
+ case 'unknown':
+ case 'nil':
+ case 'string':
+ case 'boolean':
+ case 'int':
+ case 'double':
+ case 'timestamp':
+ case 'bytes':
+ case 'string-literal':
+ case 'int-literal':
+ case 'boolean-literal':
+ case 'string-enum':
+ case 'int-enum':
+ case 'alias':
+ return false;
+ case 'document-reference':
+ return true;
+ case 'tuple':
+ return type.elements.some(schemaTypeUsesDocumentReference);
+ case 'list':
+ return schemaTypeUsesDocumentReference(type.elementType);
+ case 'map':
+ return schemaTypeUsesDocumentReference(type.valueType);
+ case 'object':
+ return type.fields.some(f => schemaTypeUsesDocumentReference(f.type));
+ case 'simple-union':
+ return type.variants.some(schemaTypeUsesDocumentReference);
+ case 'discriminated-union':
+ return type.variants.some(schemaTypeUsesDocumentReference);
+ default:
+ assertNever(type);
+ }
+}
diff --git a/src/generators/python/_types.ts b/src/generators/python/_types.ts
index 10c0d41..246ac5e 100644
--- a/src/generators/python/_types.ts
+++ b/src/generators/python/_types.ts
@@ -28,6 +28,14 @@ export type PythonDeclaration = PythonAliasDeclaration | PythonEnumClassDeclarat
export interface PythonGeneration {
type: 'python';
declarations: PythonDeclaration[];
+ /**
+ * Whether the generated file references the Firestore `DocumentReference`
+ * class anywhere. The renderer uses this to decide whether to emit the
+ * `from google.cloud import firestore` import. We avoid emitting that
+ * import unconditionally because not every Typesync user pulls in
+ * `google-cloud-firestore` (it is a transitive dep of `firebase-admin`).
+ */
+ usesDocumentReference: boolean;
}
export interface PythonGeneratorConfig {
diff --git a/src/generators/rules/__tests__/generator.test.ts b/src/generators/rules/__tests__/generator.test.ts
index 3ed6025..b1df740 100644
--- a/src/generators/rules/__tests__/generator.test.ts
+++ b/src/generators/rules/__tests__/generator.test.ts
@@ -29,6 +29,7 @@ describe('RulesGeneratorImpl', () => {
ADouble: { model: 'alias', type: 'double' },
ATs: { model: 'alias', type: 'timestamp' },
ABytes: { model: 'alias', type: 'bytes' },
+ ARef: { model: 'alias', type: 'document-reference' },
});
const generation = createGenerator().generate(s);
@@ -40,6 +41,7 @@ describe('RulesGeneratorImpl', () => {
ADouble: { type: 'number' },
ATs: { type: 'timestamp' },
ABytes: { type: 'bytes' },
+ ARef: { type: 'path' },
};
expect(generation.typeValidatorDeclarations).toHaveLength(Object.keys(expectedRulesTypeByModelName).length);
diff --git a/src/generators/rules/_adjust-schema.ts b/src/generators/rules/_adjust-schema.ts
index 3e5b5d5..6d03854 100644
--- a/src/generators/rules/_adjust-schema.ts
+++ b/src/generators/rules/_adjust-schema.ts
@@ -75,6 +75,7 @@ export function adjustSchemaForRules(prevSchema: schema.Schema): schema.rules.Sc
case 'double':
case 'timestamp':
case 'bytes':
+ case 'document-reference':
case 'string-literal':
case 'int-literal':
case 'boolean-literal':
diff --git a/src/generators/rules/_converters.ts b/src/generators/rules/_converters.ts
index 7122137..66f1384 100644
--- a/src/generators/rules/_converters.ts
+++ b/src/generators/rules/_converters.ts
@@ -38,6 +38,10 @@ export function bytesTypeToRules(_t: schema.rules.types.Bytes): rules.Bytes {
return { type: 'bytes' };
}
+export function documentReferenceTypeToRules(_t: schema.rules.types.DocumentReference): rules.Path {
+ return { type: 'path' };
+}
+
export function stringLiteralTypeToRules(t: schema.rules.types.StringLiteral): rules.Literal {
return { type: 'literal', value: t.value };
}
@@ -127,6 +131,8 @@ export function flatTypeToRules(t: schema.rules.types.Type): rules.Type {
return timestampTypeToRules(t);
case 'bytes':
return bytesTypeToRules(t);
+ case 'document-reference':
+ return documentReferenceTypeToRules(t);
case 'string-literal':
return stringLiteralTypeToRules(t);
case 'int-literal':
diff --git a/src/generators/rules/_has-readonly-field.ts b/src/generators/rules/_has-readonly-field.ts
index e229499..a35eef6 100644
--- a/src/generators/rules/_has-readonly-field.ts
+++ b/src/generators/rules/_has-readonly-field.ts
@@ -12,6 +12,7 @@ export function typeHasReadonlyField(t: schema.rules.types.Type, adjustedSchema:
case 'double':
case 'timestamp':
case 'bytes':
+ case 'document-reference':
case 'string-literal':
case 'int-literal':
case 'boolean-literal':
diff --git a/src/generators/rules/_readonly-field-predicates.ts b/src/generators/rules/_readonly-field-predicates.ts
index 3fbcf5b..83db028 100644
--- a/src/generators/rules/_readonly-field-predicates.ts
+++ b/src/generators/rules/_readonly-field-predicates.ts
@@ -27,6 +27,7 @@ export function readonlyFieldPredicateForType(
case 'double':
case 'timestamp':
case 'bytes':
+ case 'document-reference':
case 'string-literal':
case 'int-literal':
case 'boolean-literal':
diff --git a/src/generators/rules/_type-predicates.ts b/src/generators/rules/_type-predicates.ts
index a73a7ad..f29d0bd 100644
--- a/src/generators/rules/_type-predicates.ts
+++ b/src/generators/rules/_type-predicates.ts
@@ -33,6 +33,10 @@ export function typePredicateForBytesType(t: rules.Bytes, varName: string): rule
return { type: 'type-equality', varName, varType: t };
}
+export function typePredicateForPathType(t: rules.Path, varName: string): rules.Predicate {
+ return { type: 'type-equality', varName, varType: t };
+}
+
export function typePredicateForLiteralType(t: rules.Literal, varName: string): rules.Predicate {
return { type: 'value-equality', varName, varValue: typeof t.value === 'string' ? `'${t.value}'` : `${t.value}` };
}
@@ -174,6 +178,8 @@ export function typePredicateForType(t: rules.Type, varName: string, ctx: Contex
return typePredicateForTimestampType(t, varName);
case 'bytes':
return typePredicateForBytesType(t, varName);
+ case 'path':
+ return typePredicateForPathType(t, varName);
case 'literal':
return typePredicateForLiteralType(t, varName);
case 'enum':
diff --git a/src/generators/swift/__tests__/generator.test.ts b/src/generators/swift/__tests__/generator.test.ts
index 3d398df..cfde25d 100644
--- a/src/generators/swift/__tests__/generator.test.ts
+++ b/src/generators/swift/__tests__/generator.test.ts
@@ -26,6 +26,7 @@ describe('SwiftGeneratorImpl', () => {
DoubleAlias: { model: 'alias', type: 'double' },
TimestampAlias: { model: 'alias', type: 'timestamp' },
BytesAlias: { model: 'alias', type: 'bytes' },
+ ReferenceAlias: { model: 'alias', type: 'document-reference' },
});
const generation = createGenerator().generate(s);
@@ -40,6 +41,7 @@ describe('SwiftGeneratorImpl', () => {
DoubleAlias: { type: 'double' },
TimestampAlias: { type: 'date' },
BytesAlias: { type: 'data' },
+ ReferenceAlias: { type: 'document-reference' },
};
expect(generation.declarations).toHaveLength(Object.keys(expectedSwiftTypeByModelName).length);
diff --git a/src/generators/swift/_adjust-schema.ts b/src/generators/swift/_adjust-schema.ts
index e2e44c1..c0272ee 100644
--- a/src/generators/swift/_adjust-schema.ts
+++ b/src/generators/swift/_adjust-schema.ts
@@ -115,6 +115,7 @@ export function adjustSchemaForSwift(prevSchema: schema.Schema): schema.swift.Sc
case 'double':
case 'timestamp':
case 'bytes':
+ case 'document-reference':
case 'string-literal':
case 'int-literal':
case 'boolean-literal':
diff --git a/src/generators/swift/_converters.ts b/src/generators/swift/_converters.ts
index 111ef8f..a05ad20 100644
--- a/src/generators/swift/_converters.ts
+++ b/src/generators/swift/_converters.ts
@@ -38,6 +38,10 @@ export function bytesTypeToSwift(_t: schema.swift.types.Bytes): swift.Data {
return { type: 'data' };
}
+export function documentReferenceTypeToSwift(t: schema.swift.types.DocumentReference): swift.DocumentReference {
+ return t.model !== undefined ? { type: 'document-reference', model: t.model } : { type: 'document-reference' };
+}
+
export function stringLiteralTypeToSwift(_t: schema.swift.types.StringLiteral): swift.String {
return { type: 'string' };
}
@@ -99,6 +103,8 @@ export function flatTypeToSwift(t: schema.swift.types.Type): swift.Type {
return timestampTypeToSwift(t);
case 'bytes':
return bytesTypeToSwift(t);
+ case 'document-reference':
+ return documentReferenceTypeToSwift(t);
case 'string-literal':
return stringLiteralTypeToSwift(t);
case 'int-literal':
diff --git a/src/generators/swift/_impl.ts b/src/generators/swift/_impl.ts
index 23aca33..1d38b17 100644
--- a/src/generators/swift/_impl.ts
+++ b/src/generators/swift/_impl.ts
@@ -63,6 +63,7 @@ class SwiftGeneratorImpl implements SwiftGenerator {
case 'double':
case 'timestamp':
case 'bytes':
+ case 'document-reference':
case 'string-literal':
case 'int-literal':
case 'boolean-literal':
diff --git a/src/generators/ts/__tests__/generator.test.ts b/src/generators/ts/__tests__/generator.test.ts
index 0822914..05fc147 100644
--- a/src/generators/ts/__tests__/generator.test.ts
+++ b/src/generators/ts/__tests__/generator.test.ts
@@ -23,6 +23,7 @@ describe('TSGeneratorImpl', () => {
DoubleAlias: { model: 'alias', type: 'double' },
TimestampAlias: { model: 'alias', type: 'timestamp' },
BytesAlias: { model: 'alias', type: 'bytes' },
+ ReferenceAlias: { model: 'alias', type: 'document-reference' },
});
const generation = createGenerator().generate(s);
@@ -37,6 +38,7 @@ describe('TSGeneratorImpl', () => {
DoubleAlias: { type: 'number' },
TimestampAlias: { type: 'timestamp' },
BytesAlias: { type: 'bytes' },
+ ReferenceAlias: { type: 'document-reference' },
};
expect(generation.declarations).toHaveLength(Object.keys(expectedTsTypeByModelName).length);
@@ -292,6 +294,21 @@ describe('TSGeneratorImpl', () => {
]);
});
+ it('threads the parameterized `model` through document-reference into the TS platform type so the renderer can narrow it', () => {
+ const s = schema.createSchemaFromDefinition({
+ User: {
+ model: 'document',
+ path: 'users/{userId}',
+ type: { type: 'object', fields: { name: { type: 'string' } } },
+ },
+ OwnerRef: { model: 'alias', type: { type: 'document-reference', model: 'User' } },
+ });
+
+ const generation = createGenerator().generate(s);
+ const owner = generation.declarations.find(d => d.modelName === 'OwnerRef');
+ expect(owner?.modelType).toEqual({ type: 'document-reference', model: 'User' });
+ });
+
it('does not mutate the input schema', () => {
const s = schema.createSchemaFromDefinition({
Profile: {
diff --git a/src/generators/ts/_converters.ts b/src/generators/ts/_converters.ts
index 886efbe..bae92d0 100644
--- a/src/generators/ts/_converters.ts
+++ b/src/generators/ts/_converters.ts
@@ -38,6 +38,10 @@ export function bytesTypeToTS(_t: schema.ts.types.Bytes): ts.Bytes {
return { type: 'bytes' };
}
+export function documentReferenceTypeToTS(t: schema.ts.types.DocumentReference): ts.DocumentReference {
+ return t.model !== undefined ? { type: 'document-reference', model: t.model } : { type: 'document-reference' };
+}
+
export function stringLiteralTypeToTS(t: schema.ts.types.StringLiteral): ts.Literal {
return { type: 'literal', value: t.value };
}
@@ -110,6 +114,8 @@ export function typeToTS(t: schema.ts.types.Type): ts.Type {
return timestampTypeToTS(t);
case 'bytes':
return bytesTypeToTS(t);
+ case 'document-reference':
+ return documentReferenceTypeToTS(t);
case 'string-literal':
return stringLiteralTypeToTS(t);
case 'int-literal':
diff --git a/src/generators/zod/__tests__/generator.test.ts b/src/generators/zod/__tests__/generator.test.ts
index 4054d86..9648f36 100644
--- a/src/generators/zod/__tests__/generator.test.ts
+++ b/src/generators/zod/__tests__/generator.test.ts
@@ -28,6 +28,7 @@ describe('ZodGeneratorImpl', () => {
declarations: [],
usesTimestamp: false,
usesBytes: false,
+ usesDocumentReference: false,
});
});
@@ -163,22 +164,25 @@ describe('ZodGeneratorImpl', () => {
expect(profile?.expression).toContain(`bio: z.string().optional()`);
});
- it('reports usesTimestamp/usesBytes accurately by walking every model type', () => {
- const sBoth = schema.createSchemaFromDefinition({
+ it('reports usesTimestamp/usesBytes/usesDocumentReference accurately by walking every model type', () => {
+ const sAll = schema.createSchemaFromDefinition({
Stamp: { model: 'alias', type: 'timestamp' },
Blob: { model: 'alias', type: 'bytes' },
+ OwnerRef: { model: 'alias', type: 'document-reference' },
});
- const gBoth = createGenerator().generate(sBoth);
- expect(gBoth.usesTimestamp).toBe(true);
- expect(gBoth.usesBytes).toBe(true);
+ const gAll = createGenerator().generate(sAll);
+ expect(gAll.usesTimestamp).toBe(true);
+ expect(gAll.usesBytes).toBe(true);
+ expect(gAll.usesDocumentReference).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);
+ expect(gNone.usesDocumentReference).toBe(false);
});
- it('detects bytes/timestamp usage even when nested inside lists/maps/objects', () => {
+ it('detects bytes/timestamp/document-reference usage even when nested inside lists/maps/objects', () => {
const s = schema.createSchemaFromDefinition({
Doc: {
model: 'document',
@@ -188,6 +192,7 @@ describe('ZodGeneratorImpl', () => {
fields: {
createdAt: { type: 'timestamp' },
attachments: { type: { type: 'list', elementType: 'bytes' } },
+ owners: { type: { type: 'map', valueType: 'document-reference' } },
},
},
},
@@ -195,6 +200,39 @@ describe('ZodGeneratorImpl', () => {
const generation = createGenerator().generate(s);
expect(generation.usesTimestamp).toBe(true);
expect(generation.usesBytes).toBe(true);
+ expect(generation.usesDocumentReference).toBe(true);
+ });
+
+ it('narrows the cast generic for a parameterized cross-model reference and suppresses it for a self-reference', () => {
+ const s = schema.createSchemaFromDefinition({
+ Target: {
+ model: 'document',
+ path: 'targets/{targetId}',
+ type: { type: 'object', fields: { name: { type: 'string' } } },
+ },
+ NoteLink: {
+ model: 'document',
+ path: 'notes/{noteId}',
+ type: {
+ type: 'object',
+ fields: {
+ linked: { type: { type: 'document-reference', model: 'Target' } },
+ next: { type: { type: 'document-reference', model: 'NoteLink' } },
+ },
+ },
+ },
+ });
+ const generation = createGenerator({ variant: 'v4' }).generate(s);
+ const noteLink = generation.declarations.find(d => d.modelName === 'NoteLink');
+ // Cross-model references narrow to the target model's name.
+ expect(noteLink?.expression).toContain(
+ 'linked: z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)'
+ );
+ // Self-references fall back to the unnarrowed cast to avoid TS2456 on
+ // the `z.infer`-derived type alias.
+ expect(noteLink?.expression).toContain(
+ 'next: z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)'
+ );
});
it('does not mutate the input schema', () => {
diff --git a/src/generators/zod/_impl.ts b/src/generators/zod/_impl.ts
index bf3f535..08d9638 100644
--- a/src/generators/zod/_impl.ts
+++ b/src/generators/zod/_impl.ts
@@ -1,7 +1,7 @@
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 { typeUsesBytes, typeUsesDocumentReference, typeUsesTimestamp } from './_type-traversal.js';
import type { ZodGeneration, ZodGenerator, ZodGeneratorConfig, ZodSchemaDeclaration } from './_types.js';
class ZodGeneratorImpl implements ZodGenerator {
@@ -26,12 +26,14 @@ class ZodGeneratorImpl implements ZodGenerator {
const usesTimestamp = this.schemaUses(s, typeUsesTimestamp);
const usesBytes = this.schemaUses(s, typeUsesBytes);
+ const usesDocumentReference = this.schemaUses(s, typeUsesDocumentReference);
return {
type: 'zod',
declarations,
usesTimestamp,
usesBytes,
+ usesDocumentReference,
};
}
@@ -40,7 +42,10 @@ class ZodGeneratorImpl implements ZodGenerator {
modelKind: 'alias' | 'document',
emitter: ReturnType
): ZodSchemaDeclaration {
- const expression = this.attachModelDocs(buildZodFromType(model.type, emitter), model.docs);
+ const expression = this.attachModelDocs(
+ buildZodFromType(model.type, emitter, { currentModel: model.name }),
+ model.docs
+ );
return {
type: 'schema',
modelName: model.name,
diff --git a/src/generators/zod/_type-traversal.ts b/src/generators/zod/_type-traversal.ts
index 80c6f3f..38924d8 100644
--- a/src/generators/zod/_type-traversal.ts
+++ b/src/generators/zod/_type-traversal.ts
@@ -7,11 +7,11 @@ import { assertNever } from '../../util/assert.js';
* 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.
+ * is walked separately by the caller, so any `timestamp`/`bytes`/`document-reference`
+ * usage is eventually visited at its definition site regardless of how many
+ * alias references point at it.
*/
-function typeContains(type: schema.types.Type, kind: 'timestamp' | 'bytes'): boolean {
+function typeContains(type: schema.types.Type, kind: 'timestamp' | 'bytes' | 'document-reference'): boolean {
switch (type.type) {
case 'any':
case 'unknown':
@@ -31,6 +31,8 @@ function typeContains(type: schema.types.Type, kind: 'timestamp' | 'bytes'): boo
return kind === 'timestamp';
case 'bytes':
return kind === 'bytes';
+ case 'document-reference':
+ return kind === 'document-reference';
case 'tuple':
return type.elements.some(el => typeContains(el, kind));
case 'list':
@@ -55,3 +57,7 @@ export function typeUsesTimestamp(type: schema.types.Type): boolean {
export function typeUsesBytes(type: schema.types.Type): boolean {
return typeContains(type, 'bytes');
}
+
+export function typeUsesDocumentReference(type: schema.types.Type): boolean {
+ return typeContains(type, 'document-reference');
+}
diff --git a/src/generators/zod/_types.ts b/src/generators/zod/_types.ts
index 2095884..e039d44 100644
--- a/src/generators/zod/_types.ts
+++ b/src/generators/zod/_types.ts
@@ -44,6 +44,12 @@ export interface ZodGeneration {
* (`Buffer` for the admin SDK; `firestore.Bytes`/`firestore.Blob` otherwise).
*/
usesBytes: boolean;
+ /**
+ * Whether the generated file references the Firestore `DocumentReference`
+ * class anywhere. The renderer pairs this with `usesTimestamp`/`usesBytes`
+ * when deciding whether the Firestore SDK import is needed.
+ */
+ usesDocumentReference: boolean;
}
export interface ZodGeneratorConfig {
diff --git a/src/platforms/python/_expressions.ts b/src/platforms/python/_expressions.ts
index 4e8ee94..3778e68 100644
--- a/src/platforms/python/_expressions.ts
+++ b/src/platforms/python/_expressions.ts
@@ -8,6 +8,7 @@ import type {
Datetime,
Dict,
DiscriminatedUnion,
+ DocumentReference,
Float,
Int,
List,
@@ -60,6 +61,16 @@ export function expressionForBytesType(_t: Bytes): Expression {
return { content: 'bytes' };
}
+/**
+ * The Python Firestore SDK exposes document references as
+ * `google.cloud.firestore.DocumentReference`. We render this as
+ * `firestore.DocumentReference` and rely on the renderer to add a
+ * matching `from google.cloud import firestore` import.
+ */
+export function expressionForDocumentReferenceType(_t: DocumentReference): Expression {
+ return { content: 'firestore.DocumentReference' };
+}
+
export function expressionForLiteralType(t: Literal): Expression {
switch (typeof t.value) {
case 'string':
@@ -124,6 +135,8 @@ export function expressionForType(t: Type): Expression {
return expressionForDatetimeType(t);
case 'bytes':
return expressionForBytesType(t);
+ case 'document-reference':
+ return expressionForDocumentReferenceType(t);
case 'literal':
return expressionForLiteralType(t);
case 'tuple':
diff --git a/src/platforms/python/_guards.ts b/src/platforms/python/_guards.ts
index db0206b..e556e5a 100644
--- a/src/platforms/python/_guards.ts
+++ b/src/platforms/python/_guards.ts
@@ -10,6 +10,7 @@ export function isPrimitiveType(t: Type): t is Primitive {
case 'bool':
case 'datetime':
case 'bytes':
+ case 'document-reference':
case 'int':
case 'float':
return true;
diff --git a/src/platforms/python/_types.ts b/src/platforms/python/_types.ts
index 1dbbe2f..c897ac3 100644
--- a/src/platforms/python/_types.ts
+++ b/src/platforms/python/_types.ts
@@ -34,7 +34,24 @@ export interface Bytes {
readonly type: 'bytes';
}
-export type Primitive = Undefined | Any | None | Str | Bool | Int | Float | Datetime | Bytes;
+/**
+ * A Firestore document reference. Maps to `firestore.DocumentReference` in
+ * the generated Python output (i.e. `google.cloud.firestore.DocumentReference`,
+ * which is what `firebase_admin.firestore.client()` hands back). Firestore
+ * only allows storing document references in fields, hence the explicit name.
+ *
+ * The optional `model` field carries the target-model name through from the
+ * schema for completeness. The Python Firestore client's
+ * `DocumentReference` class is not generic, so the emitted type is always
+ * `firestore.DocumentReference` regardless of `model`; the schema-level
+ * validation that `model` resolves still runs.
+ */
+export interface DocumentReference {
+ readonly type: 'document-reference';
+ readonly model?: string;
+}
+
+export type Primitive = Undefined | Any | None | Str | Bool | Int | Float | Datetime | Bytes | DocumentReference;
export interface Literal {
readonly type: 'literal';
diff --git a/src/platforms/rules/_guards.ts b/src/platforms/rules/_guards.ts
index 1c15b8d..f647d8f 100644
--- a/src/platforms/rules/_guards.ts
+++ b/src/platforms/rules/_guards.ts
@@ -10,6 +10,7 @@ export function isRulesDataType(t: Type): t is RulesDataType {
case 'number':
case 'timestamp':
case 'bytes':
+ case 'path':
case 'list':
case 'map':
return true;
diff --git a/src/platforms/rules/_types.ts b/src/platforms/rules/_types.ts
index a26c886..76af9dc 100644
--- a/src/platforms/rules/_types.ts
+++ b/src/platforms/rules/_types.ts
@@ -30,7 +30,16 @@ export interface Bytes {
readonly type: 'bytes';
}
-export type Primitive = Any | String | Bool | Float | Int | Number | Timestamp | Bytes;
+/**
+ * A Firestore document reference. The Cloud Firestore Security Rules
+ * language exposes document references as the built-in `path` type, so we
+ * mirror that name here.
+ */
+export interface Path {
+ readonly type: 'path';
+}
+
+export type Primitive = Any | String | Bool | Float | Int | Number | Timestamp | Bytes | Path;
export interface Literal {
readonly type: 'literal';
@@ -87,4 +96,4 @@ export interface Alias {
export type Type = Primitive | Literal | Enum | Tuple | List | Map | Object | DiscriminatedUnion | SimpleUnion | Alias;
-export type RulesDataType = String | Bool | Float | Int | Number | Timestamp | Bytes | List | Map;
+export type RulesDataType = String | Bool | Float | Int | Number | Timestamp | Bytes | Path | List | Map;
diff --git a/src/platforms/swift/_expressions.ts b/src/platforms/swift/_expressions.ts
index 2f65a8e..f561662 100644
--- a/src/platforms/swift/_expressions.ts
+++ b/src/platforms/swift/_expressions.ts
@@ -6,6 +6,7 @@ import type {
Data,
Date,
Dictionary,
+ DocumentReference,
Double,
Int,
List,
@@ -51,6 +52,16 @@ export function expressionForDataType(_t: Data): Expression {
return { content: 'Data' };
}
+/**
+ * Firestore document references are exposed by the iOS SDK as
+ * `FirebaseFirestore.DocumentReference`. The renderer adds the
+ * `import FirebaseFirestore` whenever a reference appears in the
+ * generation, which lets us emit the bare type name here.
+ */
+export function expressionForDocumentReferenceType(_t: DocumentReference): Expression {
+ return { content: 'DocumentReference' };
+}
+
export function expressionForTupleType(t: Tuple): Expression {
const commaSeparatedExpressions = t.elements.map(vt => expressionForType(vt).content).join(', ');
return { content: `(${commaSeparatedExpressions})` };
@@ -88,6 +99,8 @@ export function expressionForType(t: Type): Expression {
return expressionForDateType(t);
case 'data':
return expressionForDataType(t);
+ case 'document-reference':
+ return expressionForDocumentReferenceType(t);
case 'tuple':
return expressionForTupleType(t);
case 'list':
diff --git a/src/platforms/swift/_guards.ts b/src/platforms/swift/_guards.ts
index ccf01c7..fd79b71 100644
--- a/src/platforms/swift/_guards.ts
+++ b/src/platforms/swift/_guards.ts
@@ -11,6 +11,7 @@ export function isPrimitiveType(t: Type): t is Primitive {
case 'double':
case 'date':
case 'data':
+ case 'document-reference':
return true;
case 'tuple':
case 'list':
diff --git a/src/platforms/swift/_types.ts b/src/platforms/swift/_types.ts
index 4cfb324..b323373 100644
--- a/src/platforms/swift/_types.ts
+++ b/src/platforms/swift/_types.ts
@@ -30,7 +30,21 @@ export interface Data {
readonly type: 'data';
}
-export type Primitive = Any | Nil | String | Bool | Int | Double | Date | Data;
+/**
+ * A Firestore document reference. Maps to `DocumentReference` (from
+ * `FirebaseFirestore`) in the generated Swift output.
+ *
+ * The optional `model` field carries the schema-level target-model name for
+ * completeness. The Firebase iOS SDK's `DocumentReference` class is not
+ * generic, so the emitted Swift type is always `DocumentReference`; the
+ * schema-level validation that `model` resolves still runs.
+ */
+export interface DocumentReference {
+ readonly type: 'document-reference';
+ readonly model?: string;
+}
+
+export type Primitive = Any | Nil | String | Bool | Int | Double | Date | Data | DocumentReference;
export interface Tuple {
readonly type: 'tuple';
diff --git a/src/platforms/ts/_expressions.ts b/src/platforms/ts/_expressions.ts
index 63e9964..7746f61 100644
--- a/src/platforms/ts/_expressions.ts
+++ b/src/platforms/ts/_expressions.ts
@@ -7,6 +7,7 @@ import type {
Any,
Boolean,
Bytes,
+ DocumentReference,
Enum,
List,
Literal,
@@ -87,6 +88,21 @@ export function expressionForBytesType(_t: Bytes, options: ExpressionOptions): E
}
}
+/**
+ * The Firestore SDKs all expose document references as `DocumentReference`
+ * under the `firestore` import, so we emit the same expression for every
+ * target. When `model` is set, the reference is narrowed to
+ * `firestore.DocumentReference`; otherwise it falls back to
+ * `firestore.DocumentReference`, which lines up with
+ * what the SDKs hand back when no per-collection converter is in play.
+ */
+export function expressionForDocumentReferenceType(t: DocumentReference): Expression {
+ if (t.model !== undefined) {
+ return { content: `firestore.DocumentReference<${t.model}>` };
+ }
+ return { content: 'firestore.DocumentReference' };
+}
+
export function expressionForLiteralType(t: Literal): Expression {
switch (typeof t.value) {
case 'string':
@@ -178,6 +194,8 @@ export function expressionForType(t: Type, options: ExpressionOptions): Expressi
return expressionForTimestampType(t);
case 'bytes':
return expressionForBytesType(t, options);
+ case 'document-reference':
+ return expressionForDocumentReferenceType(t);
case 'literal':
return expressionForLiteralType(t);
case 'enum':
diff --git a/src/platforms/ts/_guards.ts b/src/platforms/ts/_guards.ts
index db4e095..4289e69 100644
--- a/src/platforms/ts/_guards.ts
+++ b/src/platforms/ts/_guards.ts
@@ -11,6 +11,7 @@ export function isPrimitiveType(t: Type): t is Primitive {
case 'number':
case 'timestamp':
case 'bytes':
+ case 'document-reference':
return true;
case 'literal':
case 'enum':
diff --git a/src/platforms/ts/_types.ts b/src/platforms/ts/_types.ts
index 675cf1b..99db8e8 100644
--- a/src/platforms/ts/_types.ts
+++ b/src/platforms/ts/_types.ts
@@ -30,7 +30,25 @@ export interface Bytes {
readonly type: 'bytes';
}
-export type Primitive = Any | Unknown | Null | String | Boolean | Number | Timestamp | Bytes;
+/**
+ * A Firestore document reference (the `firestore.DocumentReference` runtime
+ * class). Maps to `firestore.DocumentReference` in
+ * the generated TypeScript output regardless of the active Firebase SDK
+ * target, except when `model` is set: then the emitted type is narrowed to
+ * `firestore.DocumentReference`. Firestore only supports
+ * document references as stored values, so the type intentionally mirrors
+ * the SDK class name.
+ */
+export interface DocumentReference {
+ readonly type: 'document-reference';
+ /**
+ * Optional name of the target model. When set, the TypeScript expression
+ * is narrowed to `firestore.DocumentReference`.
+ */
+ readonly model?: string;
+}
+
+export type Primitive = Any | Unknown | Null | String | Boolean | Number | Timestamp | Bytes | DocumentReference;
export interface Literal {
readonly type: 'literal';
diff --git a/src/renderers/python/__tests__/__file_snapshots__/document-reference.py b/src/renderers/python/__tests__/__file_snapshots__/document-reference.py
new file mode 100644
index 0000000..5054100
--- /dev/null
+++ b/src/renderers/python/__tests__/__file_snapshots__/document-reference.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+import typing
+import datetime
+import enum
+import pydantic
+from pydantic_core import core_schema
+from typing_extensions import Annotated
+from google.cloud import firestore
+
+class TypesyncUndefined:
+ """Do not use this class in your code. Use the `UNDEFINED` sentinel instead."""
+ _instance = None
+
+ def __init__(self):
+ if TypesyncUndefined._instance is not None:
+ raise RuntimeError("TypesyncUndefined instances cannot be created directly. Import and use the UNDEFINED sentinel instead.")
+ else:
+ TypesyncUndefined._instance = self
+
+ @classmethod
+ def __get_pydantic_core_schema__(cls, source, handler) -> core_schema.CoreSchema:
+ return core_schema.with_info_plain_validator_function(cls.validate)
+
+ @classmethod
+ def validate(cls, value: typing.Any, info) -> TypesyncUndefined:
+ if not isinstance(value, cls):
+ raise ValueError("Undefined field type is not valid")
+ return value
+
+UNDEFINED = TypesyncUndefined()
+"""A sentinel value that can be used to indicate that a value should be undefined. During serialization all values that are marked as undefined will be removed. The difference between `UNDEFINED` and `None` is that values that are set to `None` will serialize to explicit null."""
+
+class TypesyncModel(pydantic.BaseModel):
+ def model_dump(self, **kwargs) -> typing.Dict[str, typing.Any]:
+ processed = {}
+ for field_name, field_value in dict(self).items():
+ if isinstance(field_value, pydantic.BaseModel):
+ processed[field_name] = field_value.model_dump(**kwargs)
+ elif isinstance(field_value, list):
+ processed[field_name] = [item.model_dump(**kwargs) if isinstance(item, pydantic.BaseModel) else item for item in field_value]
+ elif isinstance(field_value, dict):
+ processed[field_name] = {key: value.model_dump(**kwargs) if isinstance(value, pydantic.BaseModel) else value for key, value in field_value.items()}
+ elif field_value is UNDEFINED:
+ continue
+ else:
+ processed[field_name] = field_value
+ return processed
+
+# Model Definitions
+
+OwnerRef = firestore.DocumentReference
+"""A bare reference."""
+
+OwnerRefTyped = firestore.DocumentReference
+"""A parameterized reference; Python emits the same shape as the bare form."""
+
diff --git a/src/renderers/python/__tests__/renderer.test.ts b/src/renderers/python/__tests__/renderer.test.ts
index e3b4c7a..a01b71a 100644
--- a/src/renderers/python/__tests__/renderer.test.ts
+++ b/src/renderers/python/__tests__/renderer.test.ts
@@ -17,6 +17,7 @@ describe('PythonRendererImpl', () => {
it('renders alias declarations covering every type expression with model docs', async () => {
const generation: PythonGeneration = {
type: 'python',
+ usesDocumentReference: false,
declarations: [
{
type: 'alias',
@@ -72,6 +73,7 @@ describe('PythonRendererImpl', () => {
it('renders enum-class declarations with each member as a class attribute', async () => {
const generation: PythonGeneration = {
type: 'python',
+ usesDocumentReference: false,
declarations: [
{
type: 'enum-class',
@@ -107,6 +109,7 @@ describe('PythonRendererImpl', () => {
it('renders pydantic-class declarations with optional fields, field docs, and additionalAttributes Config flag', async () => {
const generation: PythonGeneration = {
type: 'python',
+ usesDocumentReference: false,
declarations: [
{
type: 'pydantic-class',
@@ -131,6 +134,7 @@ describe('PythonRendererImpl', () => {
it('uses the configured undefined sentinel name across the static declarations and optional field defaults', async () => {
const generation: PythonGeneration = {
type: 'python',
+ usesDocumentReference: false,
declarations: [
{
type: 'pydantic-class',
@@ -149,9 +153,64 @@ describe('PythonRendererImpl', () => {
await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/custom-undefined-sentinel.py');
});
+ it('locks in the rendered Python output for both bare and parameterized document-references via a file snapshot', async () => {
+ // Python's `firestore.DocumentReference` isn't generic, so the
+ // parameterized form must emit the *same* type expression as the bare
+ // form. Locking the rendered file via a snapshot guards against an
+ // accidental change that would silently start emitting something like
+ // `firestore.DocumentReference[User]` (which would fail at import time
+ // because `DocumentReference` is not subscriptable in the SDK).
+ const generation: PythonGeneration = {
+ type: 'python',
+ usesDocumentReference: true,
+ declarations: [
+ {
+ type: 'alias',
+ modelName: 'OwnerRef',
+ modelType: { type: 'document-reference' },
+ modelDocs: 'A bare reference.',
+ },
+ {
+ type: 'alias',
+ modelName: 'OwnerRefTyped',
+ modelType: { type: 'document-reference', model: 'User' },
+ modelDocs: 'A parameterized reference; Python emits the same shape as the bare form.',
+ },
+ ],
+ };
+
+ const result = await createRenderer().render(generation);
+ await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/document-reference.py');
+ });
+
+ it('imports `firestore` from google.cloud only when `usesDocumentReference` is true', async () => {
+ const baseDeclaration = {
+ type: 'alias' as const,
+ modelName: 'OwnerRef',
+ modelType: { type: 'document-reference' as const },
+ modelDocs: null,
+ };
+
+ const withoutReference = await createRenderer().render({
+ type: 'python',
+ usesDocumentReference: false,
+ declarations: [],
+ });
+ expect(withoutReference.content).not.toContain('from google.cloud import firestore');
+
+ const withReference = await createRenderer().render({
+ type: 'python',
+ usesDocumentReference: true,
+ declarations: [baseDeclaration],
+ });
+ expect(withReference.content).toContain('from google.cloud import firestore');
+ expect(withReference.content).toContain('OwnerRef = firestore.DocumentReference');
+ });
+
it('uses a custom Pydantic base class import and parent when customPydanticBase is provided', async () => {
const generation: PythonGeneration = {
type: 'python',
+ usesDocumentReference: false,
declarations: [
{
type: 'pydantic-class',
diff --git a/src/renderers/python/_impl.ts b/src/renderers/python/_impl.ts
index e07c14d..0da5e99 100644
--- a/src/renderers/python/_impl.ts
+++ b/src/renderers/python/_impl.ts
@@ -23,12 +23,12 @@ class PythonRendererImpl implements PythonRenderer {
public async render(g: PythonGeneration): Promise {
const b = new StringBuilder();
- b.append(`${this.generateImportStatements()}\n\n`);
+ b.append(`${this.generateImportStatements(g)}\n\n`);
b.append(`${this.generateStaticDeclarations()}\n\n`);
b.append(`# Model Definitions\n\n`);
g.declarations.forEach(declaration => {
- b.append(`${this.renderDeclaration(declaration)}\n\n`);
+ b.append(`${this.renderDeclaration(declaration, g)}\n\n`);
});
const rootFile: RenderedFile = {
@@ -38,7 +38,7 @@ class PythonRendererImpl implements PythonRenderer {
return rootFile;
}
- private generateImportStatements() {
+ private generateImportStatements(g: PythonGeneration) {
const b = new StringBuilder();
b.append(`from __future__ import annotations\n\n`);
b.append(`import typing\n`);
@@ -47,6 +47,13 @@ class PythonRendererImpl implements PythonRenderer {
b.append(`import pydantic\n`);
b.append(`from pydantic_core import core_schema\n`);
b.append(`from typing_extensions import Annotated`);
+ if (g.usesDocumentReference) {
+ // `google-cloud-firestore` is a transitive dep of `firebase-admin`, so any
+ // Typesync user generating Python models for Firestore already has it
+ // installed. Importing it only when the schema actually contains a
+ // `document-reference` field keeps the generated file lean for everyone else.
+ b.append(`\nfrom google.cloud import firestore`);
+ }
if (this.config.customPydanticBase) {
const { importPath, className } = this.config.customPydanticBase;
b.append(`\nfrom ${importPath} import ${className}`);
@@ -128,14 +135,14 @@ class PythonRendererImpl implements PythonRenderer {
return b.toString();
}
- private renderDeclaration(declaration: PythonDeclaration) {
+ private renderDeclaration(declaration: PythonDeclaration, g: PythonGeneration) {
switch (declaration.type) {
case 'alias':
return this.renderAliasDeclaration(declaration);
case 'enum-class':
return this.renderEnumClassDeclaration(declaration);
case 'pydantic-class': {
- return this.renderPydanticClassDeclaration(declaration);
+ return this.renderPydanticClassDeclaration(declaration, g);
}
default:
assertNever(declaration);
@@ -179,7 +186,7 @@ class PythonRendererImpl implements PythonRenderer {
}
}
- private renderPydanticClassDeclaration(declaration: PythonPydanticClassDeclaration) {
+ private renderPydanticClassDeclaration(declaration: PythonPydanticClassDeclaration, g: PythonGeneration) {
const { undefinedSentinelName } = this.config;
const { modelName, modelType, modelDocs } = declaration;
const b = new StringBuilder();
@@ -207,7 +214,19 @@ class PythonRendererImpl implements PythonRenderer {
b.append(`${this.indent(1)}class Config:\n`);
b.append(`${this.indent(2)}use_enum_values = True\n`);
- b.append(`${this.indent(2)}extra = '${modelType.additionalAttributes ? 'allow' : 'forbid'}'\n\n`);
+ b.append(`${this.indent(2)}extra = '${modelType.additionalAttributes ? 'allow' : 'forbid'}'\n`);
+ // `firestore.DocumentReference` is a third-party class with no
+ // `__get_pydantic_core_schema__` hook, so Pydantic refuses to validate
+ // it by default. Opt every generated model into accepting arbitrary
+ // types whenever the file imports the Firestore Python client, so any
+ // model that has (or transitively references) a `document-reference`
+ // field can be validated. The opt-in is scoped per-file via the
+ // `usesDocumentReference` flag; generations without Firestore refs
+ // are unchanged.
+ if (g.usesDocumentReference) {
+ b.append(`${this.indent(2)}arbitrary_types_allowed = True\n`);
+ }
+ b.append('\n');
b.append(`${this.indent(1)}def __setattr__(self, name: str, value: typing.Any) -> None:\n`);
modelType.attributes.forEach(attribute => {
diff --git a/src/renderers/rules/__tests__/__file_snapshots__/document-reference-path.rules b/src/renderers/rules/__tests__/__file_snapshots__/document-reference-path.rules
new file mode 100644
index 0000000..05ca5f0
--- /dev/null
+++ b/src/renderers/rules/__tests__/__file_snapshots__/document-reference-path.rules
@@ -0,0 +1,23 @@
+rules_version = '2';
+service cloud.firestore {
+ // t-start
+ function isNoteLink(data) {
+ return (
+ (data is map) &&
+ (data.keys().hasOnly(['target', 'next'])) &&
+ (data.target is path) &&
+ (data.next is path)
+ );
+ }
+ // t-end
+
+ match /databases/{database}/documents {
+ function isSignedIn() {
+ return request.auth != null;
+ }
+
+ match /users/{uid} {
+ allow read, write;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/renderers/rules/__tests__/renderer.test.ts b/src/renderers/rules/__tests__/renderer.test.ts
index dd72a44..1e95f1c 100644
--- a/src/renderers/rules/__tests__/renderer.test.ts
+++ b/src/renderers/rules/__tests__/renderer.test.ts
@@ -69,6 +69,40 @@ describe('RulesRendererImpl', () => {
await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/type-validator-with-all-predicates.rules');
});
+ it('renders `data is path` for document-reference-typed fields (Firestore Rules `path` type) via a file snapshot', async () => {
+ // `document-reference` maps to the Rules `path` type — the rules
+ // language has no equivalent of TypeScript's generics so the
+ // parameterized form (with `model: User`) renders identically to the
+ // bare form. The snapshot locks in (a) that mapping, (b) that nested
+ // path references in lists/maps fold into the standard `data[i] is path`
+ // / `data[k] is path` predicates, and (c) that no model name leaks into
+ // the rendered output.
+ const generation: RulesGeneration = {
+ type: 'rules',
+ typeValidatorDeclarations: [
+ {
+ type: 'type-validator',
+ validatorName: 'isNoteLink',
+ paramName: 'data',
+ predicate: {
+ type: 'and',
+ alignment: 'vertical',
+ innerPredicates: [
+ { type: 'type-equality', varName: 'data', varType: { type: 'map' } },
+ { type: 'map-has-only-keys', varName: 'data', keys: ['target', 'next'] },
+ { type: 'type-equality', varName: 'data.target', varType: { type: 'path' } },
+ { type: 'type-equality', varName: 'data.next', varType: { type: 'path' } },
+ ],
+ },
+ },
+ ],
+ readonlyFieldValidatorDeclarations: [],
+ };
+
+ const result = await createRenderer().render(generation);
+ await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/document-reference-path.rules');
+ });
+
it('renders a readonly-field-validator declaration with prev/next data params and the affected-keys predicate', async () => {
const generation: RulesGeneration = {
type: 'rules',
diff --git a/src/renderers/swift/__tests__/__file_snapshots__/document-reference.swift b/src/renderers/swift/__tests__/__file_snapshots__/document-reference.swift
new file mode 100644
index 0000000..773c4cd
--- /dev/null
+++ b/src/renderers/swift/__tests__/__file_snapshots__/document-reference.swift
@@ -0,0 +1,9 @@
+import Foundation
+import FirebaseFirestore
+
+/// A bare reference.
+typealias OwnerRef = DocumentReference
+
+/// A parameterized reference; Swift emits the same shape as the bare form.
+typealias OwnerRefTyped = DocumentReference
+
diff --git a/src/renderers/swift/__tests__/renderer.test.ts b/src/renderers/swift/__tests__/renderer.test.ts
index 3f6eda4..86b65e9 100644
--- a/src/renderers/swift/__tests__/renderer.test.ts
+++ b/src/renderers/swift/__tests__/renderer.test.ts
@@ -158,6 +158,71 @@ describe('SwiftRendererImpl', () => {
await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/document-id-struct.swift');
});
+ it('locks in the rendered Swift output for both bare and parameterized document-references via a file snapshot', async () => {
+ // Swift's `DocumentReference` (FirebaseFirestore) is not generic, so the
+ // parameterized form must emit the *same* typealias as the bare form
+ // and the `import FirebaseFirestore` must be present. A snapshot keeps
+ // both pieces visible in one file.
+ const generation: SwiftGeneration = {
+ type: 'swift',
+ declarations: [
+ {
+ type: 'typealias',
+ modelName: 'OwnerRef',
+ modelType: { type: 'document-reference' },
+ modelDocs: 'A bare reference.',
+ },
+ {
+ type: 'typealias',
+ modelName: 'OwnerRefTyped',
+ modelType: { type: 'document-reference', model: 'User' },
+ modelDocs: 'A parameterized reference; Swift emits the same shape as the bare form.',
+ },
+ ],
+ };
+
+ const result = await createRenderer().render(generation);
+ await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/document-reference.swift');
+ });
+
+ it('imports FirebaseFirestore when any property resolves to DocumentReference (even nested in a list)', async () => {
+ const baseGeneration = (modelType: SwiftGeneration['declarations'][number]): SwiftGeneration => ({
+ type: 'swift',
+ declarations: [modelType],
+ });
+
+ const withDirectRef = await createRenderer().render(
+ baseGeneration({
+ type: 'typealias',
+ modelName: 'OwnerRef',
+ modelDocs: null,
+ modelType: { type: 'document-reference' },
+ })
+ );
+ expect(withDirectRef.content).toContain('import FirebaseFirestore');
+ expect(withDirectRef.content).toContain('typealias OwnerRef = DocumentReference');
+
+ const withListOfRefs = await createRenderer().render(
+ baseGeneration({
+ type: 'typealias',
+ modelName: 'OwnerRefs',
+ modelDocs: null,
+ modelType: { type: 'list', elementType: { type: 'document-reference' } },
+ })
+ );
+ expect(withListOfRefs.content).toContain('import FirebaseFirestore');
+
+ const withoutRefs = await createRenderer().render(
+ baseGeneration({
+ type: 'typealias',
+ modelName: 'Username',
+ modelDocs: null,
+ modelType: { type: 'string' },
+ })
+ );
+ expect(withoutRefs.content).not.toContain('import FirebaseFirestore');
+ });
+
it('renders a struct whose @DocumentID property is renamed via swift.documentIdProperty.name (so a body-side `id` field can coexist)', async () => {
const generation: SwiftGeneration = {
type: 'swift',
diff --git a/src/renderers/swift/_impl.ts b/src/renderers/swift/_impl.ts
index 6c85c06..571d2f3 100644
--- a/src/renderers/swift/_impl.ts
+++ b/src/renderers/swift/_impl.ts
@@ -50,7 +50,7 @@ class SwiftRendererImpl implements SwiftRenderer {
}
private requiresFirestoreImport(g: SwiftGeneration): boolean {
- return g.declarations.some(d => d.type === 'struct' && d.modelType.documentIdProperty !== null);
+ return g.declarations.some(d => declarationUsesFirestore(d));
}
private renderDeclaration(declaration: SwiftDeclaration) {
@@ -312,3 +312,55 @@ class SwiftRendererImpl implements SwiftRenderer {
export function createSwiftRenderer(config: SwiftRendererConfig): SwiftRenderer {
return new SwiftRendererImpl(config);
}
+
+/**
+ * A Swift declaration "needs Firestore" when it either has an auto-generated
+ * `@DocumentID` property (which is what the iOS SDK populates on read) or when
+ * any of its property types resolves to `DocumentReference`. Detecting the
+ * latter requires walking the type tree because references may be nested
+ * inside lists, dictionaries, tuples, etc.
+ */
+function declarationUsesFirestore(d: SwiftDeclaration): boolean {
+ switch (d.type) {
+ case 'struct': {
+ if (d.modelType.documentIdProperty !== null) return true;
+ const allProperties = [...d.modelType.literalProperties, ...d.modelType.regularProperties];
+ return allProperties.some(p => swiftTypeUsesDocumentReference(p.type));
+ }
+ case 'typealias':
+ return swiftTypeUsesDocumentReference(d.modelType);
+ case 'simple-union-enum':
+ return d.modelType.values.some(v => swiftTypeUsesDocumentReference(v.type));
+ case 'string-enum':
+ case 'int-enum':
+ case 'discriminated-union-enum':
+ return false;
+ default:
+ assertNever(d);
+ }
+}
+
+function swiftTypeUsesDocumentReference(t: swift.Type): boolean {
+ switch (t.type) {
+ case 'document-reference':
+ return true;
+ case 'any':
+ case 'nil':
+ case 'string':
+ case 'bool':
+ case 'int':
+ case 'double':
+ case 'date':
+ case 'data':
+ case 'alias':
+ return false;
+ case 'tuple':
+ return t.elements.some(swiftTypeUsesDocumentReference);
+ case 'list':
+ return swiftTypeUsesDocumentReference(t.elementType);
+ case 'dictionary':
+ return swiftTypeUsesDocumentReference(t.valueType);
+ default:
+ assertNever(t);
+ }
+}
diff --git a/src/renderers/ts/__tests__/__file_snapshots__/document-reference.ts b/src/renderers/ts/__tests__/__file_snapshots__/document-reference.ts
new file mode 100644
index 0000000..14052d4
--- /dev/null
+++ b/src/renderers/ts/__tests__/__file_snapshots__/document-reference.ts
@@ -0,0 +1,11 @@
+import type * as firestore from 'firebase-admin/firestore';
+
+/** A bare reference (no narrowing). */
+export type OwnerRef = firestore.DocumentReference;
+
+/** A parameterized reference narrowed to `User`. */
+export type OwnerRefTyped = firestore.DocumentReference;
+
+export interface User {
+ name: string;
+}
diff --git a/src/renderers/ts/__tests__/renderer.test.ts b/src/renderers/ts/__tests__/renderer.test.ts
index 4309697..83b15ee 100644
--- a/src/renderers/ts/__tests__/renderer.test.ts
+++ b/src/renderers/ts/__tests__/renderer.test.ts
@@ -180,4 +180,93 @@ describe('TSRendererImpl', () => {
'export type Payload = firestore.Blob;'
);
});
+
+ it('renders document-reference as firestore.DocumentReference for every target family', async () => {
+ const generation: TSGeneration = {
+ type: 'ts',
+ declarations: [
+ { type: 'alias', modelName: 'OwnerRef', modelType: { type: 'document-reference' }, modelDocs: null },
+ ],
+ };
+
+ const expectedSubstring = 'export type OwnerRef = firestore.DocumentReference;';
+
+ await expect((await createRenderer({ target: 'firebase-admin@13' }).render(generation)).content).toContain(
+ expectedSubstring
+ );
+ await expect((await createRenderer({ target: 'firebase@10' }).render(generation)).content).toContain(
+ expectedSubstring
+ );
+ await expect((await createRenderer({ target: 'react-native-firebase@21' }).render(generation)).content).toContain(
+ expectedSubstring
+ );
+ });
+
+ it('locks in the rendered TS output for both bare and parameterized document-references via a file snapshot', async () => {
+ // One focused snapshot per renderer makes the `document-reference`
+ // contract reviewable as a file diff: model docs, the firestore import,
+ // the bare `DocumentReference`, the narrowed
+ // `DocumentReference`, and the sibling target interface are
+ // all visible in one place. A regression in any of those touches a single
+ // small snapshot file rather than several inline `.toContain(...)` checks.
+ const generation: TSGeneration = {
+ type: 'ts',
+ declarations: [
+ {
+ type: 'alias',
+ modelName: 'OwnerRef',
+ modelType: { type: 'document-reference' },
+ modelDocs: 'A bare reference (no narrowing).',
+ },
+ {
+ type: 'alias',
+ modelName: 'OwnerRefTyped',
+ modelType: { type: 'document-reference', model: 'User' },
+ modelDocs: 'A parameterized reference narrowed to `User`.',
+ },
+ {
+ type: 'interface',
+ modelName: 'User',
+ modelDocs: null,
+ modelType: {
+ type: 'object',
+ additionalProperties: false,
+ properties: [{ name: 'name', type: { type: 'string' }, optional: false, docs: null }],
+ },
+ },
+ ],
+ };
+
+ const result = await createRenderer().render(generation);
+ await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/document-reference.ts');
+ });
+
+ it('narrows document-reference to firestore.DocumentReference when the parameterized form has a `model`', async () => {
+ const generation: TSGeneration = {
+ type: 'ts',
+ declarations: [
+ {
+ type: 'alias',
+ modelName: 'OwnerRef',
+ modelType: { type: 'document-reference', model: 'User' },
+ modelDocs: null,
+ },
+ ],
+ };
+
+ const expectedSubstring = 'export type OwnerRef = firestore.DocumentReference;';
+
+ // The narrowing is independent of the SDK target: every Firestore SDK
+ // exposes `DocumentReference` as a generic class so the emitted
+ // expression is identical across families.
+ await expect((await createRenderer({ target: 'firebase-admin@13' }).render(generation)).content).toContain(
+ expectedSubstring
+ );
+ await expect((await createRenderer({ target: 'firebase@10' }).render(generation)).content).toContain(
+ expectedSubstring
+ );
+ await expect((await createRenderer({ target: 'react-native-firebase@21' }).render(generation)).content).toContain(
+ expectedSubstring
+ );
+ });
});
diff --git a/src/renderers/zod/__tests__/__file_snapshots__/document-reference.ts b/src/renderers/zod/__tests__/__file_snapshots__/document-reference.ts
new file mode 100644
index 0000000..eaa3a28
--- /dev/null
+++ b/src/renderers/zod/__tests__/__file_snapshots__/document-reference.ts
@@ -0,0 +1,19 @@
+import * as firestore from 'firebase-admin/firestore';
+import { z } from 'zod';
+
+/** A bare reference (no narrowing). */
+export const OwnerRefSchema = z
+ .instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)
+ .describe('A bare reference (no narrowing).');
+export type OwnerRef = z.infer;
+
+export const NoteLinkSchema = z.strictObject({
+ target: z.instanceof(
+ firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference
+ ),
+ next: z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference),
+});
+export type NoteLink = z.infer;
+
+export const UserSchema = z.strictObject({ name: z.string() });
+export type User = z.infer;
diff --git a/src/renderers/zod/__tests__/renderer.test.ts b/src/renderers/zod/__tests__/renderer.test.ts
index 3866c5f..787b9b1 100644
--- a/src/renderers/zod/__tests__/renderer.test.ts
+++ b/src/renderers/zod/__tests__/renderer.test.ts
@@ -16,6 +16,7 @@ describe('ZodRendererImpl', () => {
type: 'zod',
usesTimestamp: true,
usesBytes: false,
+ usesDocumentReference: false,
declarations: [
{
type: 'schema',
@@ -47,6 +48,7 @@ describe('ZodRendererImpl', () => {
type: 'zod',
usesTimestamp: false,
usesBytes: false,
+ usesDocumentReference: false,
declarations: [
{
type: 'schema',
@@ -73,11 +75,12 @@ describe('ZodRendererImpl', () => {
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 () => {
+ it('omits the Firestore SDK import when no model uses timestamp, bytes, or document-reference', async () => {
const generation: ZodGeneration = {
type: 'zod',
usesTimestamp: false,
usesBytes: false,
+ usesDocumentReference: false,
declarations: [
{
type: 'schema',
@@ -101,6 +104,7 @@ describe('ZodRendererImpl', () => {
type: 'zod',
usesTimestamp: false,
usesBytes: true,
+ usesDocumentReference: false,
declarations: [
{
type: 'schema',
@@ -118,6 +122,30 @@ describe('ZodRendererImpl', () => {
expect(result.content).not.toContain(`firebase-admin/firestore`);
});
+ it('emits the Firestore SDK import for the admin target when document-reference is used (DocumentReference lives under firestore)', async () => {
+ const generation: ZodGeneration = {
+ type: 'zod',
+ usesTimestamp: false,
+ usesBytes: false,
+ usesDocumentReference: true,
+ declarations: [
+ {
+ type: 'schema',
+ modelName: 'OwnerRef',
+ schemaName: 'OwnerRefSchema',
+ inferredTypeName: null,
+ modelDocs: null,
+ modelKind: 'alias',
+ expression:
+ 'z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)',
+ },
+ ],
+ };
+
+ const result = await createRenderer({ target: 'firebase-admin@13' }).render(generation);
+ expect(result.content).toContain(`import * as firestore from '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'],
@@ -134,6 +162,7 @@ describe('ZodRendererImpl', () => {
type: 'zod',
usesTimestamp: true,
usesBytes: false,
+ usesDocumentReference: false,
declarations: [
{
type: 'schema',
@@ -155,11 +184,69 @@ describe('ZodRendererImpl', () => {
}
});
+ it('locks in the rendered Zod output for bare, parameterized cross-model, and self-referencing document-references via a file snapshot', async () => {
+ // The Zod codegen emitter (a) narrows the cast generic for parameterized
+ // *cross-model* references so `z.infer` carries
+ // `DocumentReference`, and (b) intentionally suppresses
+ // narrowing for *self-references* to avoid TS2456 on the resulting type
+ // alias. A snapshot captures all three shapes (bare, cross-model
+ // narrowed, self-reference unnarrowed) alongside the `firestore` +
+ // `zod` imports in one file so a regression in either rule is reviewable
+ // as a one-line diff.
+ const documentReferenceCast = (generic?: string) => {
+ const narrowed =
+ generic !== undefined ? `firestore.DocumentReference<${generic}>` : 'firestore.DocumentReference';
+ return `z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => ${narrowed})`;
+ };
+
+ const generation: ZodGeneration = {
+ type: 'zod',
+ usesTimestamp: false,
+ usesBytes: false,
+ usesDocumentReference: true,
+ declarations: [
+ {
+ type: 'schema',
+ modelName: 'OwnerRef',
+ schemaName: 'OwnerRefSchema',
+ inferredTypeName: 'OwnerRef',
+ modelDocs: 'A bare reference (no narrowing).',
+ modelKind: 'alias',
+ expression: `${documentReferenceCast()}.describe("A bare reference (no narrowing).")`,
+ },
+ {
+ type: 'schema',
+ modelName: 'NoteLink',
+ schemaName: 'NoteLinkSchema',
+ inferredTypeName: 'NoteLink',
+ modelDocs: null,
+ modelKind: 'document',
+ // `target` narrows to `User` (cross-model); `next` is a
+ // self-reference and falls back to the unnarrowed cast.
+ expression: `z.strictObject({ target: ${documentReferenceCast('User')}, next: ${documentReferenceCast()} })`,
+ },
+ {
+ type: 'schema',
+ modelName: 'User',
+ schemaName: 'UserSchema',
+ inferredTypeName: 'User',
+ modelDocs: null,
+ modelKind: 'document',
+ expression: 'z.strictObject({ name: z.string() })',
+ },
+ ],
+ };
+
+ const result = await createRenderer().render(generation);
+ await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/document-reference.ts');
+ });
+
it('respects the configured indentation', async () => {
const generation: ZodGeneration = {
type: 'zod',
usesTimestamp: false,
usesBytes: false,
+ usesDocumentReference: false,
declarations: [
{
type: 'schema',
diff --git a/src/renderers/zod/_impl.ts b/src/renderers/zod/_impl.ts
index b9d34c8..e8de155 100644
--- a/src/renderers/zod/_impl.ts
+++ b/src/renderers/zod/_impl.ts
@@ -60,15 +60,20 @@ class ZodRendererImpl implements ZodRenderer {
/**
* 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.
+ * intentionally key off the generation's `usesTimestamp` / `usesBytes` /
+ * `usesDocumentReference` 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);
+ // `DocumentReference` always lives under the Firestore SDK namespace
+ // (every supported target), so any document-reference usage forces the
+ // import.
+ const needsDocumentReferenceImport = g.usesDocumentReference;
- if (!needsTimestampImport && !needsBytesImport) return null;
+ if (!needsTimestampImport && !needsBytesImport && !needsDocumentReferenceImport) return null;
return getFirestoreImportForTarget(this.config.target);
}
diff --git a/src/schema/abstract.ts b/src/schema/abstract.ts
index 645f108..c6a7c1e 100644
--- a/src/schema/abstract.ts
+++ b/src/schema/abstract.ts
@@ -95,6 +95,10 @@ export abstract class AbstractSchema, D extends Do
return this.aliasModelsById.get(modelName);
}
+ public getDocumentModel(modelName: string) {
+ return this.documentModelsById.get(modelName);
+ }
+
protected cloneModels>(toSchema: S) {
const aliasModelClones = Array.from(this.aliasModelsById.values()).map(m => m.clone() as A);
const documentModelClones = Array.from(this.documentModels.values()).map(m => m.clone() as D);
diff --git a/src/schema/core/__tests__/validate-type.test.ts b/src/schema/core/__tests__/validate-type.test.ts
index b8d8e8b..ab3c298 100644
--- a/src/schema/core/__tests__/validate-type.test.ts
+++ b/src/schema/core/__tests__/validate-type.test.ts
@@ -74,6 +74,39 @@ describe('schema.validateType()', () => {
});
});
+ describe('document-reference', () => {
+ it('does not throw for the bare (non-parameterized) form', () => {
+ const schema = createSchema();
+ const t: types.DocumentReference = { type: 'document-reference' };
+ expect(() => schema.validateType(t)).not.toThrow();
+ });
+
+ it('does not throw when `model` refers to an existing alias model', () => {
+ const schema = createSchemaWithModels([
+ createAliasModel({
+ name: 'UserData',
+ docs: null,
+ value: { type: 'object', additionalFields: false, fields: [] },
+ }),
+ ]);
+ const t: types.DocumentReference = { type: 'document-reference', model: 'UserData' };
+ expect(() => schema.validateType(t)).not.toThrow();
+ });
+
+ it('throws when `model` refers to a model that is not defined in the schema', () => {
+ const schema = createSchema();
+ const t: types.DocumentReference = { type: 'document-reference', model: 'Ghost' };
+ expect(() => schema.validateType(t)).toThrow(/Ghost/);
+ });
+
+ it('rejects an empty `model` string at parse time (forbidden by the zod schema)', () => {
+ const schema = createSchema();
+ // Cast away the type-level constraint to test the runtime guard.
+ const t = { type: 'document-reference', model: '' } as unknown as types.DocumentReference;
+ expect(() => schema.validateType(t)).toThrow();
+ });
+ });
+
describe('string-literal', () => {
it('does not throw if the type is valid', () => {
const schema = createSchema();
diff --git a/src/schema/core/_zod-schemas.ts b/src/schema/core/_zod-schemas.ts
index f91bed2..7c92983 100644
--- a/src/schema/core/_zod-schemas.ts
+++ b/src/schema/core/_zod-schemas.ts
@@ -61,6 +61,29 @@ export function createZodSchemasForSchema(schema: Schema) {
})
.strict();
+ const documentReferenceType = z
+ .object({
+ type: z.literal('document-reference'),
+ model: z.string().min(1).optional(),
+ })
+ .strict()
+ .superRefine((candidate, ctx) => {
+ if (candidate.model === undefined) return;
+ // The parameterized form must reference an existing model so that
+ // generators are guaranteed to find the target when emitting a
+ // narrowed `DocumentReference`.
+ const aliasModel = schema.getAliasModel(candidate.model);
+ const documentModel = schema.getDocumentModel(candidate.model);
+ if (aliasModel === undefined && documentModel === undefined) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `The model name '${candidate.model}' referenced by a 'document-reference' type does not exist in this schema.`,
+ fatal: true,
+ });
+ return z.NEVER;
+ }
+ });
+
const primitiveType = anyType
.or(unknownType)
.or(nilType)
@@ -69,7 +92,8 @@ export function createZodSchemasForSchema(schema: Schema) {
.or(intType)
.or(doubleType)
.or(timestampType)
- .or(bytesType);
+ .or(bytesType)
+ .or(documentReferenceType);
const stringLiteralType = z
.object({
@@ -389,6 +413,7 @@ export function createZodSchemasForSchema(schema: Schema) {
doubleType,
timestampType,
bytesType,
+ documentReferenceType,
primitiveType,
stringLiteralType,
intLiteralType,
diff --git a/src/schema/core/impl.ts b/src/schema/core/impl.ts
index 136d329..96c1ce8 100644
--- a/src/schema/core/impl.ts
+++ b/src/schema/core/impl.ts
@@ -1,3 +1,5 @@
+import { z } from 'zod';
+
import { converters } from '../../converters/index.js';
import { definition } from '../../definition/index.js';
import { InvalidSchemaTypeError } from '../../errors/invalid-schema-type.js';
@@ -73,18 +75,61 @@ export class Schema extends SchemaClass<
const parseRes = type.safeParse(t);
if (!parseRes.success) {
const { error } = parseRes;
- const [issue] = error.issues;
- if (issue) {
- const { message } = issue;
- throw new InvalidSchemaTypeError(message);
- } else {
- throw new InvalidSchemaTypeError('Cannot parse type due to an unexpected error.');
- }
+ const message = pickMostSpecificIssueMessage(error);
+ throw new InvalidSchemaTypeError(message ?? 'Cannot parse type due to an unexpected error.');
}
return parseRes.data;
}
}
+/**
+ * Picks the most informative issue message from a Zod error.
+ *
+ * Zod v4 surfaces union-branch failures as a single top-level
+ * `invalid_union` issue whose `message` is the generic "Invalid input"
+ * string; the per-branch issues (including `custom` issues raised by
+ * `superRefine`) live under `issue.errors[i][j]`. Naively returning
+ * `error.issues[0].message` therefore drops the user-friendly message the
+ * superRefines worked hard to produce (e.g. "The alias name 'Foo' does not
+ * refer to an existing model in this schema." or "The model name 'Ghost'
+ * referenced by a 'document-reference' type does not exist in this
+ * schema.").
+ *
+ * We walk every nested issue depth-first and prefer:
+ * 1. The first `code: 'custom'` issue we find (these are emitted by our
+ * `superRefine` hooks and are the most user-actionable).
+ * 2. Failing that, the first non-`invalid_union` issue we find (since
+ * `invalid_union` is the umbrella error we want to skip).
+ * 3. Failing that, the top-level message as a last resort.
+ *
+ * This keeps the existing top-level-message behavior for non-union failures
+ * and only changes what we surface when the top-level issue is the generic
+ * `invalid_union` wrapper.
+ */
+function pickMostSpecificIssueMessage(error: z.ZodError): string | undefined {
+ const seen: z.core.$ZodIssue[] = [];
+ const visit = (issue: z.core.$ZodIssue): void => {
+ seen.push(issue);
+ // `invalid_union` issues nest the per-branch failures in `.errors`.
+ const nested = (issue as { errors?: z.core.$ZodIssue[][] }).errors;
+ if (Array.isArray(nested)) {
+ for (const branch of nested) {
+ for (const childIssue of branch) {
+ visit(childIssue);
+ }
+ }
+ }
+ };
+ for (const issue of error.issues) {
+ visit(issue);
+ }
+ const custom = seen.find(i => i.code === 'custom');
+ if (custom !== undefined) return custom.message;
+ const nonUnion = seen.find(i => i.code !== 'invalid_union');
+ if (nonUnion !== undefined) return nonUnion.message;
+ return error.issues[0]?.message;
+}
+
export function createAliasModel(params: CreateAliasModelParams) {
const { name, docs, value } = params;
return new AliasModel(name, docs, value);
diff --git a/src/schema/core/types.ts b/src/schema/core/types.ts
index 97e70c3..1942b7e 100644
--- a/src/schema/core/types.ts
+++ b/src/schema/core/types.ts
@@ -22,6 +22,7 @@ export {
Double,
Timestamp,
Bytes,
+ DocumentReference,
Primitive,
StringLiteral,
IntLiteral,
diff --git a/src/schema/generic.ts b/src/schema/generic.ts
index e08116e..acb8946 100644
--- a/src/schema/generic.ts
+++ b/src/schema/generic.ts
@@ -38,7 +38,35 @@ export interface Bytes {
type: 'bytes';
}
-export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp | Bytes;
+/**
+ * A Firestore document reference value. Firestore lets you store a reference
+ * to another document directly inside a document — i.e. a pointer to a
+ * location like `users/alice` — rather than its string id. Each platform
+ * exposes it as its idiomatic class (`firestore.DocumentReference` in
+ * TypeScript, `DocumentReference` in Swift, `firestore.DocumentReference` in
+ * Python) and Security Rules sees it as a `path`.
+ *
+ * Firestore only allows storing document references in fields (not collection
+ * references), so the name mirrors the SDK class `DocumentReference` exactly
+ * to make that constraint obvious at the schema-definition layer.
+ *
+ * When `model` is set, generators whose SDK class is generic (TypeScript and
+ * Zod) narrow the emitted type to `DocumentReference`; the
+ * generators whose SDK class is not generic (Python, Swift) ignore the model
+ * at the type level but the schema validator still rejects references to
+ * unknown models so the contract is checked everywhere.
+ */
+export interface DocumentReference {
+ type: 'document-reference';
+ /**
+ * Optional name of the target model (alias or document). Validated against
+ * the schema when present: a reference whose `model` doesn't resolve to a
+ * model in the same schema is rejected at schema-build time.
+ */
+ model?: string;
+}
+
+export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp | Bytes | DocumentReference;
export interface StringLiteral {
type: 'string-literal';
@@ -215,5 +243,6 @@ export interface Schema {
addModelGroup(models: (A | D)[]): void;
addModel(model: A | D): void;
getAliasModel(modelName: string): A | undefined;
+ getDocumentModel(modelName: string): D | undefined;
validateType(type: unknown): void;
}
diff --git a/src/schema/python/types.ts b/src/schema/python/types.ts
index 677d952..fc960ec 100644
--- a/src/schema/python/types.ts
+++ b/src/schema/python/types.ts
@@ -21,6 +21,7 @@ export {
Double,
Timestamp,
Bytes,
+ DocumentReference,
Primitive,
StringLiteral,
IntLiteral,
diff --git a/src/schema/rules/types.ts b/src/schema/rules/types.ts
index 97e70c3..1942b7e 100644
--- a/src/schema/rules/types.ts
+++ b/src/schema/rules/types.ts
@@ -22,6 +22,7 @@ export {
Double,
Timestamp,
Bytes,
+ DocumentReference,
Primitive,
StringLiteral,
IntLiteral,
diff --git a/src/schema/swift/types.ts b/src/schema/swift/types.ts
index 7e2686d..41ca439 100644
--- a/src/schema/swift/types.ts
+++ b/src/schema/swift/types.ts
@@ -21,6 +21,7 @@ export {
Double,
Timestamp,
Bytes,
+ DocumentReference,
Primitive,
StringLiteral,
IntLiteral,
diff --git a/src/schema/ts/types.ts b/src/schema/ts/types.ts
index 97e70c3..1942b7e 100644
--- a/src/schema/ts/types.ts
+++ b/src/schema/ts/types.ts
@@ -22,6 +22,7 @@ export {
Double,
Timestamp,
Bytes,
+ DocumentReference,
Primitive,
StringLiteral,
IntLiteral,
diff --git a/tests/integration/_fixtures/samples/references/note-link.json b/tests/integration/_fixtures/samples/references/note-link.json
new file mode 100644
index 0000000..60c88b3
--- /dev/null
+++ b/tests/integration/_fixtures/samples/references/note-link.json
@@ -0,0 +1,13 @@
+{
+ "label": "primary-note-link",
+ "target_path": "targets/canonical",
+ "related_paths": ["notes/sibling-a", "notes/sibling-b", "notes/sibling-c"],
+ "by_label_paths": {
+ "primary": "targets/canonical",
+ "secondary": "targets/secondary",
+ "tertiary": "targets/tertiary"
+ },
+ "linked_target_path": "targets/canonical",
+ "next_note_path": "notes/sibling-a",
+ "created_at": "2024-05-09T10:00:00.000Z"
+}
diff --git a/tests/integration/_fixtures/schemas/references.yml b/tests/integration/_fixtures/schemas/references.yml
new file mode 100644
index 0000000..4a191f0
--- /dev/null
+++ b/tests/integration/_fixtures/schemas/references.yml
@@ -0,0 +1,82 @@
+# yaml-language-server: $schema=../../../../schema.local.json
+#
+# Shared integration-test fixture for the `document-reference` primitive.
+# Each Firestore SDK represents document references with a platform-native
+# class:
+#
+# - TypeScript (firebase-admin@13): firestore.DocumentReference
+# - TypeScript (firebase@10, web): firestore.DocumentReference
+# - Python (firebase-admin@6): firestore.DocumentReference
+# - Swift (firebase@10): FirebaseFirestore.DocumentReference
+#
+# The schema deliberately mixes a top-level reference, a list-of-references,
+# and a map-of-references so we exercise references in collections too. A
+# plain string + timestamp sit alongside so we confirm references coexist
+# with non-Firestore-typed fields without accidental coercion.
+#
+# It also exercises the **parameterized** form of `document-reference`:
+# `target_note` and `next_note` reference the `NoteLink` model itself
+# (self-reference), and `linked_target` references the sibling `Target`
+# document. On platforms whose SDK class is generic (TypeScript + Zod) the
+# emitted type is narrowed to `DocumentReference` /
+# `DocumentReference`; on platforms where it is not (Python, Swift)
+# the schema-level validation that the target model exists still runs.
+#
+# Doc-level convention used by every per-platform test:
+# * `target` is a reference to a sibling `/targets/{id}` document that
+# stores the canonical entity the note links to (bare form).
+# * `related` is a list of references to other notes (bare form).
+# * `by_label` is a map (free-form keys) whose values are references (bare form).
+# * `linked_target` is a parameterized reference whose target model is
+# `Target`.
+# * `next_note` is a parameterized reference whose target model is the
+# same `NoteLink` (self-reference).
+
+NoteLink:
+ model: document
+ path: notes/{noteId}
+ docs: A document that points to other documents via Firestore document references.
+ type:
+ type: object
+ fields:
+ label:
+ type: string
+ docs: Human-readable label for the link (not a reference itself).
+ target:
+ type: document-reference
+ docs: A direct reference to a `/targets/{id}` document.
+ related:
+ type:
+ type: list
+ elementType: document-reference
+ docs: A list of related-note references to exercise references nested in a list.
+ by_label:
+ type:
+ type: map
+ valueType: document-reference
+ docs: A map of label -> reference to exercise references nested in a map.
+ linked_target:
+ type:
+ type: document-reference
+ model: Target
+ docs: A parameterized reference whose target model is `Target` (different model).
+ next_note:
+ type:
+ type: document-reference
+ model: NoteLink
+ docs: A parameterized reference whose target model is `NoteLink` (self-reference).
+ created_at:
+ type: timestamp
+
+Target:
+ model: document
+ path: targets/{targetId}
+ docs: A minimal sibling document used as the target of parameterized references.
+ type:
+ type: object
+ fields:
+ name:
+ type: string
+ docs: Display name for the target.
+ created_at:
+ type: timestamp
diff --git a/tests/integration/python/tests/test_references.py b/tests/integration/python/tests/test_references.py
new file mode 100644
index 0000000..2053968
--- /dev/null
+++ b/tests/integration/python/tests/test_references.py
@@ -0,0 +1,132 @@
+"""Round-trip tests for the `references` fixture.
+
+Verifies that the `document-reference` primitive emitted by the Python
+generator (`firestore.DocumentReference` /
+`typing.List[firestore.DocumentReference]` /
+`typing.Dict[str, firestore.DocumentReference]`) round-trips correctly
+through the Firestore emulator using the official
+`google-cloud-firestore` client (which is what `firebase-admin` uses
+underneath).
+"""
+
+from __future__ import annotations
+
+import json
+import uuid
+from pathlib import Path
+
+import pytest
+from google.cloud import firestore
+
+
+def _load_sample(fixtures_root: Path, name: str) -> dict:
+ return json.loads((fixtures_root / "samples" / "references" / f"{name}.json").read_text())
+
+
+@pytest.fixture
+def references_module(import_generated_module):
+ return import_generated_module("references")
+
+
+def test_note_link_round_trips_document_references_via_firestore_emulator(
+ references_module,
+ fixtures_root: Path,
+ firestore_client: firestore.Client,
+ isolated_collection: firestore.CollectionReference,
+) -> None:
+ """A document with a top-level reference + list-of-references +
+ map-of-references survives a Pydantic-validate -> emulator-write ->
+ emulator-read -> Pydantic-validate cycle with each reference's path
+ preserved exactly."""
+
+ NoteLink = references_module.NoteLink
+
+ sample = _load_sample(fixtures_root, "note-link")
+
+ target = firestore_client.document(sample["target_path"])
+ related = [firestore_client.document(p) for p in sample["related_paths"]]
+ by_label = {k: firestore_client.document(v) for k, v in sample["by_label_paths"].items()}
+ # Parameterized references: the Python `firestore.DocumentReference` class
+ # is not generic, so the generated Pydantic field type is identical to the
+ # bare form. We still exercise the round-trip to confirm the schema-level
+ # `model` validation accepts the references and the SDK round-trips them
+ # the same way.
+ linked_target = firestore_client.document(sample["linked_target_path"])
+ next_note = firestore_client.document(sample["next_note_path"])
+
+ assert target.path == sample["target_path"]
+ assert len({r.path for r in related}) == len(related)
+ assert len(related) == 3
+ assert len(by_label) == 3
+
+ note_link_in = NoteLink.model_validate(
+ {
+ "label": sample["label"],
+ "target": target,
+ "related": related,
+ "by_label": by_label,
+ "linked_target": linked_target,
+ "next_note": next_note,
+ "created_at": sample["created_at"],
+ }
+ )
+
+ # The generator emits Pydantic types that store `DocumentReference`
+ # values verbatim; check that nothing has been auto-coerced (e.g. to
+ # a string path) before we even reach Firestore.
+ assert isinstance(note_link_in.target, firestore.DocumentReference)
+ assert all(isinstance(r, firestore.DocumentReference) for r in note_link_in.related)
+ assert all(isinstance(v, firestore.DocumentReference) for v in note_link_in.by_label.values())
+ assert isinstance(note_link_in.linked_target, firestore.DocumentReference)
+ assert isinstance(note_link_in.next_note, firestore.DocumentReference)
+
+ doc_ref = isolated_collection.document(uuid.uuid4().hex)
+ doc_ref.set(note_link_in.model_dump())
+
+ snapshot = doc_ref.get()
+ assert snapshot.exists, "expected the written document to be readable"
+
+ raw = snapshot.to_dict()
+ # Wire-level expectations: the Firestore Python client returns refs
+ # as `DocumentReference` for top-level fields, list entries, and map
+ # values.
+ assert isinstance(raw["target"], firestore.DocumentReference)
+ assert isinstance(raw["related"], list)
+ assert all(isinstance(r, firestore.DocumentReference) for r in raw["related"])
+ assert isinstance(raw["by_label"], dict)
+ assert all(isinstance(v, firestore.DocumentReference) for v in raw["by_label"].values())
+
+ # Re-validate through the generated Pydantic model to confirm the
+ # generated schema round-trips without rejecting plain
+ # `DocumentReference` values.
+ note_link_out = NoteLink.model_validate(raw)
+ assert note_link_out.label == sample["label"]
+ assert note_link_out.target.path == target.path
+ assert [r.path for r in note_link_out.related] == [r.path for r in related]
+ assert {k: v.path for k, v in note_link_out.by_label.items()} == {
+ k: v.path for k, v in by_label.items()
+ }
+ assert note_link_out.linked_target.path == linked_target.path
+ assert note_link_out.next_note.path == next_note.path
+
+
+def test_note_link_rejects_string_target(references_module, firestore_client: firestore.Client) -> None:
+ """The generated Pydantic class should reject obvious type mismatches
+ on the reference-typed fields (e.g. a bare string path where a
+ `DocumentReference` instance is expected)."""
+
+ NoteLink = references_module.NoteLink
+
+ placeholder_ref = firestore_client.document("targets/canonical")
+ with pytest.raises(Exception):
+ NoteLink.model_validate(
+ {
+ "label": "x",
+ "target": "targets/canonical",
+ "related": [],
+ "by_label": {},
+ "linked_target": placeholder_ref,
+ "next_note": placeholder_ref,
+ "created_at": "2024-01-01T00:00:00.000Z",
+ }
+ )
diff --git a/tests/integration/swift/Tests/TypesyncIntegrationTests/ReferencesIntegrationTests.swift b/tests/integration/swift/Tests/TypesyncIntegrationTests/ReferencesIntegrationTests.swift
new file mode 100644
index 0000000..35ee159
--- /dev/null
+++ b/tests/integration/swift/Tests/TypesyncIntegrationTests/ReferencesIntegrationTests.swift
@@ -0,0 +1,136 @@
+import FirebaseFirestore
+import Foundation
+import Testing
+
+@testable import TypesyncIntegration
+
+// Round-trips a `document-reference`-typed document through the Firestore
+// emulator using the **firebase-ios-sdk** Codable bridge. The Swift
+// generator emits `FirebaseFirestore.DocumentReference` for each
+// `document-reference` field, which is what the Firebase iOS SDK uses to
+// represent Firestore's `reference` value type when encoding / decoding
+// via `Firestore.Encoder` / `Firestore.Decoder`.
+//
+// We assert both the wire-level and Codable-level shapes:
+//
+// 1. `setData(from:)` accepts a `NoteLink` whose reference-typed fields
+// are `DocumentReference` values without coercion (top-level,
+// inside a list, and inside a map).
+// 2. The raw snapshot (`snapshot.data()`) exposes those fields as
+// `DocumentReference` (iOS SDK class), including refs nested inside
+// a list and a map.
+// 3. `snapshot.data(as: NoteLink.self)` rebuilds an equivalent
+// `NoteLink` whose reference paths are preserved exactly.
+
+@Suite("References document-reference round-trip via emulator")
+struct ReferencesIntegrationTests {
+ @Test("a NoteLink with DocumentReference fields (top-level + [DocumentReference] + [String: DocumentReference]) round-trips through the emulator with path preservation")
+ func noteLinkRoundTripsThroughEmulator() async throws {
+ let firestore = EmulatorClient.firestore()
+ let collection = firestore.collection(uniqueCollectionName())
+
+ let sample = try Fixtures.loadSampleAsDict(scenario: "references", name: "note-link")
+
+ let targetPath = try unwrap(sample["target_path"] as? String, "references/note-link.target_path")
+ let relatedPaths = try unwrap(sample["related_paths"] as? [String], "references/note-link.related_paths")
+ let byLabelPaths = try unwrap(sample["by_label_paths"] as? [String: String], "references/note-link.by_label_paths")
+ let linkedTargetPath = try unwrap(sample["linked_target_path"] as? String, "references/note-link.linked_target_path")
+ let nextNotePath = try unwrap(sample["next_note_path"] as? String, "references/note-link.next_note_path")
+ let label = try unwrap(sample["label"] as? String, "references/note-link.label")
+ let createdAtRaw = try unwrap(sample["created_at"] as? String, "references/note-link.created_at")
+
+ let target = firestore.document(targetPath)
+ let related = relatedPaths.map { firestore.document($0) }
+ var byLabel: [String: DocumentReference] = [:]
+ for (k, v) in byLabelPaths {
+ byLabel[k] = firestore.document(v)
+ }
+ // Parameterized references: the Swift `DocumentReference` is not
+ // generic, so the generated property type is identical to the bare
+ // form. We still round-trip them to confirm the schema-level model
+ // validation accepts the references and the iOS SDK preserves paths.
+ let linkedTarget = firestore.document(linkedTargetPath)
+ let nextNote = firestore.document(nextNotePath)
+
+ #expect(target.path == targetPath)
+ #expect(Set(related.map { $0.path }).count == related.count)
+ #expect(related.count == 3)
+ #expect(byLabel.count == 3)
+
+ let noteLinkIn = NoteLink(
+ label: label,
+ target: target,
+ related: related,
+ byLabel: byLabel,
+ linkedTarget: linkedTarget,
+ nextNote: nextNote,
+ createdAt: try parseISO8601(createdAtRaw)
+ )
+
+ let docRef = collection.document(UUID().uuidString)
+ try docRef.setData(from: noteLinkIn)
+
+ let snapshot = try await docRef.getDocument()
+ try #require(snapshot.exists)
+ let raw = try #require(snapshot.data(), "expected non-empty document data")
+
+ // Wire-level expectations: the iOS SDK exposes references through
+ // `DocumentReference` on the snapshot for top-level fields, list
+ // entries, and map values alike. Both bare-form and parameterized
+ // references share the same on-wire shape.
+ #expect(raw["target"] is DocumentReference)
+ let rawRelated = try #require(raw["related"] as? [DocumentReference], "related should decode as [DocumentReference]")
+ #expect(rawRelated.count == related.count)
+ let rawByLabel = try #require(raw["by_label"] as? [String: DocumentReference], "by_label should decode as [String: DocumentReference]")
+ #expect(rawByLabel.count == byLabel.count)
+ #expect(raw["linked_target"] is DocumentReference)
+ #expect(raw["next_note"] is DocumentReference)
+
+ let noteLinkOut = try snapshot.data(as: NoteLink.self)
+
+ #expect(noteLinkOut.label == noteLinkIn.label)
+ #expect(noteLinkOut.target.path == target.path)
+ #expect(noteLinkOut.related.count == related.count)
+ for (i, ref) in noteLinkOut.related.enumerated() {
+ #expect(ref.path == related[i].path)
+ }
+ #expect(Set(noteLinkOut.byLabel.keys) == Set(byLabel.keys))
+ for (k, expectedRef) in byLabel {
+ #expect(noteLinkOut.byLabel[k]?.path == expectedRef.path)
+ }
+ #expect(noteLinkOut.linkedTarget.path == linkedTarget.path)
+ #expect(noteLinkOut.nextNote.path == nextNote.path)
+ #expect(
+ abs(noteLinkOut.createdAt.timeIntervalSince1970 - noteLinkIn.createdAt.timeIntervalSince1970) < 0.001,
+ "timestamp should round-trip through Firestore within ms precision"
+ )
+ }
+}
+
+private func uniqueCollectionName() -> String {
+ "test_" + UUID().uuidString.replacingOccurrences(of: "-", with: "")
+}
+
+private struct FixtureError: Error, CustomStringConvertible {
+ let description: String
+}
+
+private func unwrap(_ value: T?, _ name: String) throws -> T {
+ guard let value else {
+ throw FixtureError(description: "Missing or wrongly-typed fixture field '\(name)'.")
+ }
+ return value
+}
+
+private func parseISO8601(_ raw: String) throws -> Date {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = formatter.date(from: raw) {
+ return date
+ }
+ formatter.formatOptions = [.withInternetDateTime]
+ if let date = formatter.date(from: raw) {
+ return date
+ }
+ throw FixtureError(description: "Invalid ISO-8601 date: \(raw)")
+}
diff --git a/tests/integration/typescript/generated/web/references.ts b/tests/integration/typescript/generated/web/references.ts
new file mode 100644
index 0000000..89e4c1a
--- /dev/null
+++ b/tests/integration/typescript/generated/web/references.ts
@@ -0,0 +1,25 @@
+import type * as firestore from 'firebase/firestore';
+
+/** A document that points to other documents via Firestore document references. */
+export interface NoteLink {
+ /** Human-readable label for the link (not a reference itself). */
+ label: string;
+ /** A direct reference to a `/targets/{id}` document. */
+ target: firestore.DocumentReference;
+ /** A list of related-note references to exercise references nested in a list. */
+ related: firestore.DocumentReference[];
+ /** A map of label -> reference to exercise references nested in a map. */
+ by_label: Record>;
+ /** A parameterized reference whose target model is `Target` (different model). */
+ linked_target: firestore.DocumentReference;
+ /** A parameterized reference whose target model is `NoteLink` (self-reference). */
+ next_note: firestore.DocumentReference;
+ created_at: firestore.Timestamp;
+}
+
+/** A minimal sibling document used as the target of parameterized references. */
+export interface Target {
+ /** Display name for the target. */
+ name: string;
+ created_at: firestore.Timestamp;
+}
diff --git a/tests/integration/typescript/tests/references.admin.test.ts b/tests/integration/typescript/tests/references.admin.test.ts
new file mode 100644
index 0000000..85e577d
--- /dev/null
+++ b/tests/integration/typescript/tests/references.admin.test.ts
@@ -0,0 +1,152 @@
+import { type App, initializeApp } from 'firebase-admin/app';
+import { DocumentReference, type Firestore, Timestamp, getFirestore } from 'firebase-admin/firestore';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import type { NoteLink, Target } from '../generated/references.js';
+
+// Round-trips a `document-reference`-typed document through the Firestore
+// emulator using the **firebase-admin@13** SDK. The admin SDK represents
+// document references with the `DocumentReference` class exposed under
+// `firebase-admin/firestore`; the generator emits that class for every
+// `firebase-admin@*` target. This test asserts that:
+//
+// 1. A typed `NoteLink` constructed with admin `DocumentReference` values
+// (top-level, inside a list, and inside a map) can be written to the
+// emulator without coercion.
+// 2. Reading the same document back yields `DocumentReference`-shaped
+// values for every reference field, including those nested inside the
+// list and the map.
+// 3. The target paths survive the round-trip exactly: a reference pointed
+// at `targets/canonical` before the write still points at
+// `targets/canonical` after the read.
+
+const FIXTURES_ROOT = resolve(__dirname, '../../_fixtures');
+
+interface NoteLinkSample {
+ label: string;
+ target_path: string;
+ related_paths: string[];
+ by_label_paths: Record;
+ linked_target_path: string;
+ next_note_path: string;
+ created_at: string;
+}
+
+function loadSample(scenario: string, name: string): NoteLinkSample {
+ const samplePath = resolve(FIXTURES_ROOT, 'samples', scenario, `${name}.json`);
+ return JSON.parse(readFileSync(samplePath, 'utf8')) as NoteLinkSample;
+}
+
+function ensureEmulatorEnv(): void {
+ if (!process.env.FIRESTORE_EMULATOR_HOST) {
+ throw new Error('FIRESTORE_EMULATOR_HOST is not set. Run via `yarn test:integration:typescript`.');
+ }
+ process.env.GOOGLE_CLOUD_PROJECT ??= 'demo-integration';
+ process.env.GCLOUD_PROJECT ??= process.env.GOOGLE_CLOUD_PROJECT;
+}
+
+describe('References document-reference round-trip (firebase-admin@13)', () => {
+ let app: App;
+ let firestore: Firestore;
+
+ beforeAll(() => {
+ ensureEmulatorEnv();
+ app = initializeApp({ projectId: process.env.GOOGLE_CLOUD_PROJECT }, 'references-admin-test-app');
+ firestore = getFirestore(app);
+ });
+
+ afterAll(async () => {
+ await firestore.terminate();
+ });
+
+ it('round-trips a typed NoteLink with DocumentReference-valued fields (top-level + list + map) through the emulator', async () => {
+ const sample = loadSample('references', 'note-link');
+
+ const target = firestore.doc(sample.target_path);
+ const related = sample.related_paths.map(p => firestore.doc(p));
+ const byLabel: Record = {};
+ for (const [k, v] of Object.entries(sample.by_label_paths)) {
+ byLabel[k] = firestore.doc(v);
+ }
+ // Parameterized references: the generator narrows these to
+ // `DocumentReference` and `DocumentReference` respectively.
+ // Casting via `as unknown as` is required because the admin SDK hands back
+ // an untyped `DocumentReference` from `firestore.doc()`; the
+ // wire-level value is identical, only the TypeScript view differs.
+ const linkedTarget = firestore.doc(sample.linked_target_path) as unknown as DocumentReference;
+ const nextNote = firestore.doc(sample.next_note_path) as unknown as DocumentReference;
+
+ expect(target.path).toBe(sample.target_path);
+ expect(new Set(related.map(r => r.path)).size).toBe(related.length);
+ expect(related.length).toBe(3);
+ expect(Object.keys(byLabel).length).toBe(3);
+
+ const noteLinkIn: NoteLink = {
+ label: sample.label,
+ target,
+ related,
+ by_label: byLabel,
+ linked_target: linkedTarget,
+ next_note: nextNote,
+ 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(noteLinkIn);
+
+ const snapshot = await docRef.get();
+ expect(snapshot.exists).toBe(true);
+
+ const raw = snapshot.data() as Record;
+ // Wire-level expectations: the admin SDK returns references as
+ // `DocumentReference` instances, including nested entries.
+ expect(raw.target).toBeInstanceOf(DocumentReference);
+ expect(Array.isArray(raw.related)).toBe(true);
+ for (const r of raw.related as unknown[]) {
+ expect(r).toBeInstanceOf(DocumentReference);
+ }
+ expect(typeof raw.by_label).toBe('object');
+ for (const v of Object.values(raw.by_label as Record)) {
+ expect(v).toBeInstanceOf(DocumentReference);
+ }
+
+ const noteLinkOut = snapshot.data() as NoteLink;
+
+ expect(noteLinkOut.label).toBe(noteLinkIn.label);
+ expect(noteLinkOut.target.path).toBe(target.path);
+ expect(noteLinkOut.related.length).toBe(related.length);
+ noteLinkOut.related.forEach((r, i) => {
+ const expected = related[i];
+ expect(expected).toBeDefined();
+ expect(r.path).toBe(expected!.path);
+ });
+ expect(Object.keys(noteLinkOut.by_label).sort()).toEqual(Object.keys(byLabel).sort());
+ for (const [k, expectedRef] of Object.entries(byLabel)) {
+ const got = noteLinkOut.by_label[k];
+ expect(got).toBeDefined();
+ expect(got!.path).toBe(expectedRef.path);
+ }
+ // Parameterized references round-trip with paths preserved exactly,
+ // identical to the bare form on the wire.
+ expect(noteLinkOut.linked_target.path).toBe(linkedTarget.path);
+ expect(noteLinkOut.next_note.path).toBe(nextNote.path);
+ expect(noteLinkOut.created_at.toMillis()).toBe(noteLinkIn.created_at.toMillis());
+ });
+
+ // Pure compile-time check: the generator narrows `linked_target` to
+ // `DocumentReference` and `next_note` to `DocumentReference`.
+ // Assigning these into variables typed by the *narrowed* generic parameter
+ // would fail to compile if the generator regressed to the bare
+ // `DocumentReference` shape. The test body is intentionally
+ // empty — tsc carries the assertion via the `tsc --noEmit` integration step.
+ it('emits narrowed DocumentReference types for parameterized references (compile-time check)', () => {
+ const _typeCheck = (noteLink: NoteLink): { t: DocumentReference; n: DocumentReference } => ({
+ t: noteLink.linked_target,
+ n: noteLink.next_note,
+ });
+ expect(typeof _typeCheck).toBe('function');
+ });
+});
diff --git a/tests/integration/typescript/tests/references.web.test.ts b/tests/integration/typescript/tests/references.web.test.ts
new file mode 100644
index 0000000..59e70fc
--- /dev/null
+++ b/tests/integration/typescript/tests/references.web.test.ts
@@ -0,0 +1,159 @@
+import { type FirebaseApp, initializeApp as initializeWebApp } from 'firebase/app';
+import {
+ DocumentReference,
+ type Firestore,
+ Timestamp,
+ connectFirestoreEmulator,
+ doc,
+ getDoc,
+ getFirestore,
+ setDoc,
+ terminate,
+} from 'firebase/firestore';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import type { NoteLink, Target } from '../generated/web/references.js';
+
+// Round-trips a `document-reference`-typed document through the Firestore
+// emulator using the **firebase@10 web SDK**. The web SDK exposes
+// references through the `DocumentReference` class from `firebase/firestore`
+// (a separate class from the admin SDK's `DocumentReference`, but with the
+// same on-wire representation). The generator emits the web SDK's
+// `DocumentReference` for the `firebase@*` targets and this test confirms
+// that:
+//
+// 1. A typed `NoteLink` constructed with web `DocumentReference` values
+// (top-level + nested in a list + nested in a map) is accepted by
+// `setDoc` without coercion.
+// 2. Reading back with `getDoc` returns `DocumentReference` instances
+// for every reference field.
+// 3. The reference paths are preserved exactly across the round-trip.
+
+const FIXTURES_ROOT = resolve(__dirname, '../../_fixtures');
+
+interface NoteLinkSample {
+ label: string;
+ target_path: string;
+ related_paths: string[];
+ by_label_paths: Record;
+ linked_target_path: string;
+ next_note_path: string;
+ created_at: string;
+}
+
+function loadSample(scenario: string, name: string): NoteLinkSample {
+ const samplePath = resolve(FIXTURES_ROOT, 'samples', scenario, `${name}.json`);
+ return JSON.parse(readFileSync(samplePath, 'utf8')) as NoteLinkSample;
+}
+
+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:typescript`.');
+ }
+ 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';
+ return { host, port };
+}
+
+describe('References document-reference round-trip (firebase@10 web SDK)', () => {
+ let app: FirebaseApp;
+ let firestore: Firestore;
+
+ beforeAll(() => {
+ const { host, port } = ensureEmulatorEnv();
+ app = initializeWebApp(
+ {
+ apiKey: 'fake-api-key',
+ projectId: process.env.GOOGLE_CLOUD_PROJECT,
+ },
+ 'references-web-test-app'
+ );
+ firestore = getFirestore(app);
+ connectFirestoreEmulator(firestore, host, port);
+ });
+
+ afterAll(async () => {
+ await terminate(firestore);
+ });
+
+ it('round-trips a typed NoteLink with web-SDK DocumentReference values through the emulator', async () => {
+ const sample = loadSample('references', 'note-link');
+
+ const target = doc(firestore, sample.target_path);
+ const related = sample.related_paths.map(p => doc(firestore, p));
+ const byLabel: Record = {};
+ for (const [k, v] of Object.entries(sample.by_label_paths)) {
+ byLabel[k] = doc(firestore, v);
+ }
+ const linkedTarget = doc(firestore, sample.linked_target_path) as unknown as DocumentReference;
+ const nextNote = doc(firestore, sample.next_note_path) as unknown as DocumentReference;
+
+ expect(target.path).toBe(sample.target_path);
+ expect(new Set(related.map(r => r.path)).size).toBe(related.length);
+
+ const noteLinkIn: NoteLink = {
+ label: sample.label,
+ target,
+ related,
+ by_label: byLabel,
+ linked_target: linkedTarget,
+ next_note: nextNote,
+ 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, noteLinkIn);
+
+ const snapshot = await getDoc(docRef);
+ expect(snapshot.exists()).toBe(true);
+
+ const raw = snapshot.data() as Record;
+ expect(raw.target).toBeInstanceOf(DocumentReference);
+ expect(Array.isArray(raw.related)).toBe(true);
+ for (const r of raw.related as unknown[]) {
+ expect(r).toBeInstanceOf(DocumentReference);
+ }
+ for (const v of Object.values(raw.by_label as Record)) {
+ expect(v).toBeInstanceOf(DocumentReference);
+ }
+
+ const noteLinkOut = snapshot.data() as NoteLink;
+
+ expect(noteLinkOut.label).toBe(noteLinkIn.label);
+ expect(noteLinkOut.target.path).toBe(target.path);
+ expect(noteLinkOut.related.length).toBe(related.length);
+ noteLinkOut.related.forEach((r, i) => {
+ const expected = related[i];
+ expect(expected).toBeDefined();
+ expect(r.path).toBe(expected!.path);
+ });
+ for (const [k, expectedRef] of Object.entries(byLabel)) {
+ const got = noteLinkOut.by_label[k];
+ expect(got).toBeDefined();
+ expect(got!.path).toBe(expectedRef.path);
+ }
+ expect(noteLinkOut.linked_target.path).toBe(linkedTarget.path);
+ expect(noteLinkOut.next_note.path).toBe(nextNote.path);
+ expect(noteLinkOut.created_at.toMillis()).toBe(noteLinkIn.created_at.toMillis());
+ });
+
+ it('emits narrowed DocumentReference types for parameterized references (compile-time check)', () => {
+ const _typeCheck = (noteLink: NoteLink): { t: DocumentReference; n: DocumentReference } => ({
+ t: noteLink.linked_target,
+ n: noteLink.next_note,
+ });
+ expect(typeof _typeCheck).toBe('function');
+ });
+});
diff --git a/tests/integration/zod/generated/v3/references.ts b/tests/integration/zod/generated/v3/references.ts
new file mode 100644
index 0000000..f0aa107
--- /dev/null
+++ b/tests/integration/zod/generated/v3/references.ts
@@ -0,0 +1,40 @@
+import * as firestore from 'firebase-admin/firestore';
+import { z } from 'zod-v3';
+
+/** A document that points to other documents via Firestore document references. */
+export const NoteLinkSchema = z
+ .object({
+ label: z.string().describe('Human-readable label for the link (not a reference itself).'),
+ target: z
+ .instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)
+ .describe('A direct reference to a `/targets/{id}` document.'),
+ related: z
+ .array(
+ z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)
+ )
+ .describe('A list of related-note references to exercise references nested in a list.'),
+ by_label: z
+ .record(
+ z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)
+ )
+ .describe('A map of label -> reference to exercise references nested in a map.'),
+ linked_target: z
+ .instanceof(
+ firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference
+ )
+ .describe('A parameterized reference whose target model is `Target` (different model).'),
+ next_note: z
+ .instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)
+ .describe('A parameterized reference whose target model is `NoteLink` (self-reference).'),
+ created_at: z.instanceof(firestore.Timestamp),
+ })
+ .strict()
+ .describe('A document that points to other documents via Firestore document references.');
+export type NoteLink = z.infer;
+
+/** A minimal sibling document used as the target of parameterized references. */
+export const TargetSchema = z
+ .object({ name: z.string().describe('Display name for the target.'), created_at: z.instanceof(firestore.Timestamp) })
+ .strict()
+ .describe('A minimal sibling document used as the target of parameterized references.');
+export type Target = z.infer;
diff --git a/tests/integration/zod/generated/v4-web/references.ts b/tests/integration/zod/generated/v4-web/references.ts
new file mode 100644
index 0000000..e5b3424
--- /dev/null
+++ b/tests/integration/zod/generated/v4-web/references.ts
@@ -0,0 +1,42 @@
+import * as firestore from 'firebase/firestore';
+import { z } from 'zod-v4';
+
+/** A document that points to other documents via Firestore document references. */
+export const NoteLinkSchema = z
+ .strictObject({
+ label: z.string().describe('Human-readable label for the link (not a reference itself).'),
+ target: z
+ .instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)
+ .describe('A direct reference to a `/targets/{id}` document.'),
+ related: z
+ .array(
+ z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)
+ )
+ .describe('A list of related-note references to exercise references nested in a list.'),
+ by_label: z
+ .record(
+ z.string(),
+ z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)
+ )
+ .describe('A map of label -> reference to exercise references nested in a map.'),
+ linked_target: z
+ .instanceof(
+ firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference
+ )
+ .describe('A parameterized reference whose target model is `Target` (different model).'),
+ next_note: z
+ .instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)
+ .describe('A parameterized reference whose target model is `NoteLink` (self-reference).'),
+ created_at: z.instanceof(firestore.Timestamp),
+ })
+ .describe('A document that points to other documents via Firestore document references.');
+export type NoteLink = z.infer;
+
+/** A minimal sibling document used as the target of parameterized references. */
+export const TargetSchema = z
+ .strictObject({
+ name: z.string().describe('Display name for the target.'),
+ created_at: z.instanceof(firestore.Timestamp),
+ })
+ .describe('A minimal sibling document used as the target of parameterized references.');
+export type Target = z.infer;
diff --git a/tests/integration/zod/generated/v4/references.ts b/tests/integration/zod/generated/v4/references.ts
new file mode 100644
index 0000000..7fa517c
--- /dev/null
+++ b/tests/integration/zod/generated/v4/references.ts
@@ -0,0 +1,42 @@
+import * as firestore from 'firebase-admin/firestore';
+import { z } from 'zod-v4';
+
+/** A document that points to other documents via Firestore document references. */
+export const NoteLinkSchema = z
+ .strictObject({
+ label: z.string().describe('Human-readable label for the link (not a reference itself).'),
+ target: z
+ .instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)
+ .describe('A direct reference to a `/targets/{id}` document.'),
+ related: z
+ .array(
+ z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)
+ )
+ .describe('A list of related-note references to exercise references nested in a list.'),
+ by_label: z
+ .record(
+ z.string(),
+ z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)
+ )
+ .describe('A map of label -> reference to exercise references nested in a map.'),
+ linked_target: z
+ .instanceof(
+ firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference
+ )
+ .describe('A parameterized reference whose target model is `Target` (different model).'),
+ next_note: z
+ .instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)
+ .describe('A parameterized reference whose target model is `NoteLink` (self-reference).'),
+ created_at: z.instanceof(firestore.Timestamp),
+ })
+ .describe('A document that points to other documents via Firestore document references.');
+export type NoteLink = z.infer;
+
+/** A minimal sibling document used as the target of parameterized references. */
+export const TargetSchema = z
+ .strictObject({
+ name: z.string().describe('Display name for the target.'),
+ created_at: z.instanceof(firestore.Timestamp),
+ })
+ .describe('A minimal sibling document used as the target of parameterized references.');
+export type Target = z.infer;
diff --git a/tests/integration/zod/tests/references.admin.test.ts b/tests/integration/zod/tests/references.admin.test.ts
new file mode 100644
index 0000000..6c367bd
--- /dev/null
+++ b/tests/integration/zod/tests/references.admin.test.ts
@@ -0,0 +1,91 @@
+import { type App, initializeApp } from 'firebase-admin/app';
+import { DocumentReference, type Firestore, Timestamp, getFirestore } from 'firebase-admin/firestore';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { NoteLinkSchema } from '../generated/v4/references.js';
+import { ensureEmulatorEnv, loadSample } from './_helpers.js';
+
+interface NoteLinkSample {
+ label: string;
+ target_path: string;
+ related_paths: string[];
+ by_label_paths: Record;
+ linked_target_path: string;
+ next_note_path: string;
+ created_at: string;
+}
+
+describe('references 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-references-admin-app');
+ firestore = getFirestore(app);
+ });
+
+ afterAll(async () => {
+ await firestore.terminate();
+ });
+
+ it("validates a NoteLink read from the emulator with the admin SDK's DocumentReference shape", async () => {
+ const sample = loadSample('references', 'note-link') as NoteLinkSample;
+
+ const target = firestore.doc(sample.target_path);
+ const related = sample.related_paths.map(p => firestore.doc(p));
+ const byLabel: Record = {};
+ for (const [k, v] of Object.entries(sample.by_label_paths)) {
+ byLabel[k] = firestore.doc(v);
+ }
+ const linkedTarget = firestore.doc(sample.linked_target_path);
+ const nextNote = firestore.doc(sample.next_note_path);
+
+ const noteLinkIn = {
+ label: sample.label,
+ target,
+ related,
+ by_label: byLabel,
+ linked_target: linkedTarget,
+ next_note: nextNote,
+ 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(noteLinkIn);
+
+ const snapshot = await docRef.get();
+ expect(snapshot.exists).toBe(true);
+
+ const parsed = NoteLinkSchema.safeParse(snapshot.data());
+ expect(parsed.success).toBe(true);
+ if (parsed.success) {
+ expect(parsed.data.label).toBe(noteLinkIn.label);
+ // The admin SDK returns refs as DocumentReference instances; the
+ // schema is bound to the admin SDK's DocumentReference class for
+ // this generated file (firebase-admin@13 target).
+ expect(parsed.data.target).toBeInstanceOf(DocumentReference);
+ expect(parsed.data.target.path).toBe(target.path);
+ expect(parsed.data.related).toHaveLength(related.length);
+ for (let i = 0; i < related.length; i += 1) {
+ expect(parsed.data.related[i]).toBeInstanceOf(DocumentReference);
+ expect(parsed.data.related[i]!.path).toBe(related[i]!.path);
+ }
+ for (const [k, expectedRef] of Object.entries(byLabel)) {
+ const got = parsed.data.by_label[k];
+ expect(got).toBeInstanceOf(DocumentReference);
+ expect(got!.path).toBe(expectedRef.path);
+ }
+ // Parameterized references survive the round-trip identically to the
+ // bare form. The codegen emitter narrows `linked_target` to
+ // `DocumentReference` at the type level, but the runtime
+ // `instanceof` check accepts any DocumentReference instance.
+ expect(parsed.data.linked_target).toBeInstanceOf(DocumentReference);
+ expect(parsed.data.linked_target.path).toBe(linkedTarget.path);
+ expect(parsed.data.next_note).toBeInstanceOf(DocumentReference);
+ expect(parsed.data.next_note.path).toBe(nextNote.path);
+ expect(parsed.data.created_at.toMillis()).toBe(noteLinkIn.created_at.toMillis());
+ }
+ });
+});
diff --git a/tests/integration/zod/tests/references.parsing.test.ts b/tests/integration/zod/tests/references.parsing.test.ts
new file mode 100644
index 0000000..351f7f5
--- /dev/null
+++ b/tests/integration/zod/tests/references.parsing.test.ts
@@ -0,0 +1,94 @@
+import { DocumentReference as AdminDocumentReference, Timestamp } from 'firebase-admin/firestore';
+import { describe, expect, it } from 'vitest';
+
+import * as v3Admin from '../generated/v3/references.js';
+import * as v4Web from '../generated/v4-web/references.js';
+import * as v4Admin from '../generated/v4/references.js';
+
+// Pure-Zod parsing tests for the `references` fixture (which exercises
+// the `document-reference` primitive). The reference representation
+// differs per target:
+//
+// * firebase-admin: firestore.DocumentReference from `firebase-admin/firestore`
+// * firebase web SDK: firestore.DocumentReference from `firebase/firestore`
+//
+// We exercise all three combinations the orchestrator emits today:
+// v3 admin, v4 admin, v4 web.
+
+const ts = new Timestamp(1_700_000_000, 0);
+
+/**
+ * The admin `DocumentReference` constructor is private; the SDK only
+ * exposes ways to obtain one through a `Firestore` instance. The
+ * generated schemas only check that the value is an `instanceof
+ * DocumentReference`, so a `Object.create`-based fake whose prototype is
+ * `DocumentReference.prototype` is sufficient to satisfy the schema
+ * without booting Firebase. The dedicated emulator round-trip suites
+ * (`references.admin.test.ts` / `references.web.test.ts`) cover the real
+ * SDK instances.
+ */
+function fakeAdminRef(): AdminDocumentReference {
+ return Object.create(AdminDocumentReference.prototype) as AdminDocumentReference;
+}
+
+const adminInput = () => ({
+ label: 'note link',
+ target: fakeAdminRef(),
+ related: [fakeAdminRef(), fakeAdminRef()],
+ by_label: { primary: fakeAdminRef(), secondary: fakeAdminRef() },
+ // Parameterized fields share the same runtime shape; the codegen
+ // narrows them at the type level only.
+ linked_target: fakeAdminRef(),
+ next_note: fakeAdminRef(),
+ created_at: ts,
+});
+
+describe.each([
+ { name: 'v3 admin', mod: v3Admin },
+ { name: 'v4 admin', mod: v4Admin },
+])('references admin ($name)', ({ mod }) => {
+ it('accepts a NoteLink built from DocumentReference values', () => {
+ expect(mod.NoteLinkSchema.safeParse(adminInput()).success).toBe(true);
+ });
+ it('rejects a NoteLink whose `target` is a bare string path instead of a DocumentReference', () => {
+ expect(mod.NoteLinkSchema.safeParse({ ...adminInput(), target: 'targets/canonical' }).success).toBe(false);
+ });
+ it('rejects a NoteLink whose `related` list contains a non-DocumentReference entry', () => {
+ expect(
+ mod.NoteLinkSchema.safeParse({
+ ...adminInput(),
+ related: [fakeAdminRef(), 'targets/canonical'],
+ }).success
+ ).toBe(false);
+ });
+ it('rejects a NoteLink whose `by_label` map contains a non-DocumentReference value', () => {
+ expect(
+ mod.NoteLinkSchema.safeParse({
+ ...adminInput(),
+ by_label: { primary: fakeAdminRef(), secondary: 'targets/canonical' },
+ }).success
+ ).toBe(false);
+ });
+ it('accepts empty `related` list and empty `by_label` map', () => {
+ expect(mod.NoteLinkSchema.safeParse({ ...adminInput(), related: [], by_label: {} }).success).toBe(true);
+ });
+ it('rejects a NoteLink whose parameterized `linked_target` is a bare string path', () => {
+ expect(mod.NoteLinkSchema.safeParse({ ...adminInput(), linked_target: 'targets/canonical' }).success).toBe(false);
+ });
+ it('rejects a NoteLink whose parameterized `next_note` is a bare string path', () => {
+ expect(mod.NoteLinkSchema.safeParse({ ...adminInput(), next_note: 'notes/sibling-a' }).success).toBe(false);
+ });
+});
+
+describe('references web (v4)', () => {
+ it('rejects admin-shaped DocumentReference values against the web-SDK-bound schema', () => {
+ // The web schema's `instanceof` check is bound to
+ // `firebase/firestore`'s DocumentReference, an entirely different
+ // class from `firebase-admin/firestore`'s DocumentReference. Passing
+ // an admin-shaped ref should therefore fail at parse time. We don't
+ // construct a web-SDK reference here because it requires booting
+ // the Firebase web SDK; the dedicated emulator round-trip in
+ // `references.web.test.ts` covers the positive path.
+ expect(v4Web.NoteLinkSchema.safeParse(adminInput()).success).toBe(false);
+ });
+});
diff --git a/tests/integration/zod/tests/references.web.test.ts b/tests/integration/zod/tests/references.web.test.ts
new file mode 100644
index 0000000..39eadee
--- /dev/null
+++ b/tests/integration/zod/tests/references.web.test.ts
@@ -0,0 +1,101 @@
+import { type FirebaseApp, initializeApp as initializeWebApp } from 'firebase/app';
+import {
+ DocumentReference,
+ type Firestore,
+ Timestamp,
+ connectFirestoreEmulator,
+ doc,
+ getDoc,
+ getFirestore,
+ setDoc,
+ terminate,
+} from 'firebase/firestore';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { NoteLinkSchema } from '../generated/v4-web/references.js';
+import { ensureEmulatorEnv, loadSample } from './_helpers.js';
+
+interface NoteLinkSample {
+ label: string;
+ target_path: string;
+ related_paths: string[];
+ by_label_paths: Record;
+ linked_target_path: string;
+ next_note_path: string;
+ created_at: string;
+}
+
+describe('references 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-references-web-app'
+ );
+ firestore = getFirestore(app);
+ connectFirestoreEmulator(firestore, host, port);
+ });
+
+ afterAll(async () => {
+ await terminate(firestore);
+ });
+
+ it("validates a NoteLink read from the emulator with the web SDK's `firestore.DocumentReference` shape", async () => {
+ const sample = loadSample('references', 'note-link') as NoteLinkSample;
+
+ const target = doc(firestore, sample.target_path);
+ const related = sample.related_paths.map(p => doc(firestore, p));
+ const byLabel: Record = {};
+ for (const [k, v] of Object.entries(sample.by_label_paths)) {
+ byLabel[k] = doc(firestore, v);
+ }
+ const linkedTarget = doc(firestore, sample.linked_target_path);
+ const nextNote = doc(firestore, sample.next_note_path);
+
+ const noteLinkIn = {
+ label: sample.label,
+ target,
+ related,
+ by_label: byLabel,
+ linked_target: linkedTarget,
+ next_note: nextNote,
+ 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, noteLinkIn);
+
+ const snapshot = await getDoc(docRef);
+ expect(snapshot.exists()).toBe(true);
+
+ const parsed = NoteLinkSchema.safeParse(snapshot.data());
+ expect(parsed.success).toBe(true);
+ if (parsed.success) {
+ expect(parsed.data.label).toBe(noteLinkIn.label);
+ expect(parsed.data.target).toBeInstanceOf(DocumentReference);
+ expect(parsed.data.target.path).toBe(target.path);
+ expect(parsed.data.related).toHaveLength(related.length);
+ for (let i = 0; i < related.length; i += 1) {
+ expect(parsed.data.related[i]).toBeInstanceOf(DocumentReference);
+ expect(parsed.data.related[i]!.path).toBe(related[i]!.path);
+ }
+ for (const [k, expectedRef] of Object.entries(byLabel)) {
+ const got = parsed.data.by_label[k];
+ expect(got).toBeInstanceOf(DocumentReference);
+ expect(got!.path).toBe(expectedRef.path);
+ }
+ expect(parsed.data.linked_target).toBeInstanceOf(DocumentReference);
+ expect(parsed.data.linked_target.path).toBe(linkedTarget.path);
+ expect(parsed.data.next_note).toBeInstanceOf(DocumentReference);
+ expect(parsed.data.next_note.path).toBe(nextNote.path);
+ expect(parsed.data.created_at.toMillis()).toBe(noteLinkIn.created_at.toMillis());
+ }
+ });
+});