Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/modern-parrots-search.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,6 @@ tests/integration/swift/Package.resolved
ui-debug.log
database-debug.log
pubsub-debug.log
### INTEGRATION TESTS END
### INTEGRATION TESTS END

.cursor/
108 changes: 108 additions & 0 deletions docs/schema/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,114 @@ function isValidExample(data) {

</CodeGroup>

## `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.

<Info>
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.
</Info>

<Info>
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.
</Info>

<CodeGroup>

```yaml definition.yml
Example:
model: alias
type: document-reference
```

```ts models.ts
export type Example = firestore.DocumentReference<firestore.DocumentData>;
```

```swift models.swift
typealias Example = DocumentReference
```

```python models.py
Example = firestore.DocumentReference
```

```javascript firestore.rules
function isValidExample(data) {
return (data is path);
}
```

</CodeGroup>

### 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<TargetModel>` is emitted instead of `firestore.DocumentReference<firestore.DocumentData>`.
- **Zod** — the inferred type from `z.infer<typeof Schema>` 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.

<Info>
Zod self-references (e.g. `NoteLink.next: DocumentReference<NoteLink>`) 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.
</Info>

<CodeGroup>

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

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

</CodeGroup>

## `literal`

Represents a literal type.
Expand Down
112 changes: 69 additions & 43 deletions schema.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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<TargetModel>` instead of `DocumentReference<DocumentData>`. 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": [
Expand Down
19 changes: 11 additions & 8 deletions scripts/integration-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,20 +156,23 @@ const PLATFORMS: Record<Platform, PlatformConfig> = {
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
// execute under Node.
extraGenerations: [
{
subdir: 'web',
onlyFixtures: ['secrets'],
onlyFixtures: ['secrets', 'references'],
async generate(definition, outFile) {
await typesync.generateTs({
definition,
Expand Down Expand Up @@ -241,7 +244,7 @@ const PLATFORMS: Record<Platform, PlatformConfig> = {
},
{
subdir: 'v4-web',
onlyFixtures: ['secrets'],
onlyFixtures: ['secrets', 'references'],
async generate(definition, outFile) {
await typesync.generateZod({
definition,
Expand Down
10 changes: 10 additions & 0 deletions src/converters/definition-to-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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':
Expand Down
58 changes: 57 additions & 1 deletion src/core/zod/__tests__/build-zod-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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' },
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading