firebase-contract
v0.1.0
Published
Treat a **YAML contract as the single source of truth** for a Firebase app and generate every downstream representation from it — so shared types, enums, and validation stop being hand-maintained in parallel (and stop drifting).
Maintainers
Readme
firebase-contract
Treat a YAML contract as the single source of truth for a Firebase app and generate every downstream representation from it — so shared types, enums, and validation stop being hand-maintained in parallel (and stop drifting).
From one contract, generate:
- TypeScript types (interfaces + frozen-const enums)
- Zod validation schemas
- Data Connect GraphQL schema (
@table, keys, directives), query/mutation operations (routed per connector), andAny↔logical adapters - Firestore projection schemas (denormalized read model; shared field groups via fragments)
- API request/response types, request-validation Zod, and class-validator DTOs
- Cloud Task / Pub/Sub payload envelopes + delivery constants
- SQL migrations for constraints Data Connect can't express (composite FKs, CHECK, indexes)
- Per-entity id codecs (typed encode/decode wrappers)
- Discriminated unions
- Project config (
dataconnect.yaml,connector.yaml) + sync constants
Install
npm i -D firebase-contractProvides the firebase-contract (alias fbc) CLI and a programmatic API.
Quick start
fbc init # scaffold contract.yml + firebase-contract.json
fbc validate # parse, resolve imports, semantically validate
fbc generate # run every generator declared across the contract graph
fbc inspect # print the normalized IR (debugging)
fbc where <Name> # locate the yml defining a type (logical / gqlName / fsName / table)One-command generation
Each contract file declares the generators it uses in a top-level
generators: block — a name plus an output template. Declaring a
document-scoped generator (typescript, zod, …) runs it once for
that yml; api-scoped generators (api-types, api-validation, api-dto,
task-payloads) run for the entries that opt in (see below):
# contract.yml (root)
project:
aliases:
"#contracts/*": libs/contracts/src/* # out templates may target aliases
generators:
- { generator: typescript, out: "#contracts", split: true }
- { generator: api-types, out: "#contracts/api-types/{api-name}" }
imports:
- ./apps/shop/data-connect/schema.yml# apps/shop/data-connect/schema.yml
generators:
- { generator: data-connect-graphql, out: src, split: true }
- { generator: sql-migrations, out: . }outresolves relative to the declaring yml;#alias/...prefixes resolve through the root yml'sproject.aliases(relative to the root).- Api-scoped templates may use
{api-name}(kebab-cased name) and{path}(REST path with{param}segments dropped). - Entries reference declarations by name — nearest first (same yml → root).
Output settings: file, split, options, header
Every declaration (and any entry-level application) can reshape the output — the defaults reproduce each generator's built-in layout, so omitting them changes nothing:
generators:
- generator: api-dto
out: "src/entries/{path}/dto"
file: "{api-name}.dto.ts" # file-name template (default shown)
split: true # one file per api (api-dto's default)
- generator: api-types
out: "#contracts/api-types"
file: "{api-name}.types.ts" # override the bundled default with per-api files
split: true
options:
typesImport: "../types" # where `import type { … } from '…'` points
- generator: typescript
out: "#contracts"
file: models.ts # rename a single-file/barrel output
split: true # document scope: per-table split layoutfile— output file name. Api scope: a template ({api-name}/{path}allowed whensplit: true). Document scope: renames the single output file, or the barrel of a split layout.split— api scope:trueemits one file per api (thefiletemplate must contain a placeholder),falsebundles everything into onefile. Document scope:trueselects the generator's per-item split layout (typescript, zod, firestore, data-connect-graphql, data-connect-operations) and errors for generators without one.options— free-form string map passed through to the generator. Currently:typesImport(api-types, task-payloads, data-connect-operations) overrides the types import path. For api-types and task-payloads this is normally unnecessary: when the option is absent and atypescriptgenerator is declared (nearest-first: same yml → root), the import specifier is derived automatically as the relative path from the output file to the declared barrel — moving or renaming the barrel (out/file) re-derives it. An explicittypesImportalways wins; without a typescript declaration the generator's built-in default (./types) applies.header— per-generator banner override:defaultfor the built-in AUTO-GENERATED banner, any text for a custom comment,""to suppress the banner for this generator. Wins over the contract-levelheader:and the CLI--headerflag.- Defaults per generator:
api-types→api-types.tsbundled,api-validation→api-validation.tsbundled,task-payloads→task-payloads.tsbundled,api-dto→{api-name}.dto.tssplit. - Entry-level settings override the declaration, which overrides the
generator's default (same nearest-first rule as
out).
fbc generate contract.yml materializes all declared outputs across the
whole import graph in one run. Passing -o/-g switches to single-target mode
(fbc generate <entry> -o <dir> -g typescript,zod). firebase-contract.json
holds only a default entry — output routing lives in the yml generators:
declarations, so a config file can never silently override them.
Generated-file headers
By default no banner is emitted. Opt in per run or per contract:
fbc generate --header # default AUTO-GENERATED banner
fbc generate --header "Managed by fbc" # custom text (multi-line ok)header: default # or any text; the CLI --header flag winsHeader text (contract header:, per-generator header, CLI --header)
supports ${...} template variables:
| Variable | Value |
| --- | --- |
| ${rootContractPath} | entry (root) yml path, relative to the root yml's directory |
| ${currentContractPath} | the yml the output is generated from |
| ${allContractPath} | import chain root → current, joined with -> |
| ${generatedAt} | date the file was first generated (carried over on regenerate) |
| ${updatedAt} | date the content last changed |
A regenerate that produces identical content keeps both dates and reproduces
the file byte-for-byte, so --check stays drift-free; when content changes,
generatedAt is carried over from the existing file and only updatedAt
moves. Date tokens accept an optional format — ${updatedAt:yyyy-mm-dd HH:mm:ss}
— with runs yyyy/yy, MM, dd, HH, ss, and mm meaning month before
any hour token / minute after one (both yyyy-mm-dd and HH:mm read
naturally). Default: yyyy-MM-dd.
The default banner includes the source and dates:
// AUTO-GENERATED by firebase-contract. Do not edit by hand.
// Source: ${currentContractPath}
// Generated: ${generatedAt} / Updated: ${updatedAt}
// Regenerate with: fbc generateComment syntax adapts per file type (// for TS, # for GraphQL/YAML-ish,
-- for SQL).
The YAML DSL
A contract is composed of top-level sections — enums, models, operations,
apis, firestore, unions, envelopes, project — and imports that pull
in other contracts (relative paths or npm packages). Imports are resolved
transitively with cycle/duplicate detection and diamond dedup, so you can split
a large contract across files that mirror your repo layout.
Type names are globally unique. The namespace is flat across the whole
import graph: defining the same name twice — same kind (DUPLICATE_DEFINITION)
or across kinds/derived identifiers, e.g. an enum and a model both named
Status, or two enums whose CONSTANT_CASE consts collide (NAME_COLLISION)
— aborts generation. Uniqueness is enforced per output namespace with its
effective names, so fsName/gqlName renames legitimately reuse a name in a
different output: GraphQL names are checked per data-connect-graphql scope
(services may map distinct logical names onto the same gqlName), and the
firestore module is checked under fsName ?? name alongside doc names. A bare
type reference therefore always resolves to exactly one definition — use
fbc where <Name> to find it.
Unknown keys are errors. Every key the parser does not consume is an
UNKNOWN_KEY error (all reported in one pass), never silently dropped — a
typo like optionnal: must not quietly turn into a required field. There is
no version: field: nothing consumes one, so accepting it would only fake a
compatibility mechanism that does not exist.
Out-of-vocabulary values are errors too. A value outside a field's fixed
set is INVALID_VALUE rather than a silent coercion: auth: PUBILC does not
quietly become NO_ACCESS, dir: Dsec does not become ASC, and a mistyped
action / single / keyArg / style.* is not dropped. The one exception is
where.op, which is warned (UNKNOWN_WHERE_OP) not errored — it is emitted
verbatim into GraphQL and the recognized-operator list may lag Data Connect, so
a typo is surfaced without rejecting a valid-but-unlisted operator.
imports:
- ./apps/shop/data-connect/schema.yml
- some-shared-package/contract.ymlEnums
enums:
ProductStatus:
description: Lifecycle state of a product
values: [DRAFT, PUBLISHED, ARCHIVED]
ShippingSpeed:
description: |-
Multi-line descriptions become multi-line comments.
values:
- { value: S, key: STANDARD } # const-object key override → STANDARD: 'S'
- { value: X, key: EXPRESS, description: per-value comment }Enum options: description, gqlName (GraphQL rendering name),
fsName / fsDescription (Firestore-side rename/comment — e.g. render DC's
Status as ReviewStatus in projections), and per-value key /
description overrides.
By default the TypeScript generator emits the frozen-const representation
(runtime values + Key and value types) — the shape you'd otherwise hand-write
and keep in sync with the DC enum and the Zod enum:
export const PRODUCT_STATUS = Object.freeze({ DRAFT: 'DRAFT', PUBLISHED: 'PUBLISHED', ARCHIVED: 'ARCHIVED' } as const)
export type ProductStatusKey = keyof typeof PRODUCT_STATUS
export type ProductStatus = (typeof PRODUCT_STATUS)[ProductStatusKey]For a plain 'OPEN' | 'DONE' | 'DISABLED' union instead, construct the
TypeScript generator with enumStyle: 'union' via the programmatic API
(createTypeScriptGenerator({ enumStyle: 'union' })). This is a build-time
generator option, not a YAML contract field.
Models
models:
User:
fields:
id: { type: id, id: true }
name: string # shorthand — the value is the type
email: { type: string, email: true }
Product:
description: A unit of work
key: [catalog, productNo] # composite primary key
indexes:
- { fields: [catalog, productNo], unique: true }
- { fields: [status, createdAt] }
fields:
catalog: { type: Catalog, relation: true }
productNo: int
title: { type: string, nonempty: true, maxLength: 200 }
status: ProductStatus
metadata: { type: ProductMetadata, optional: true } # embedded → Any
createdAt: timestampScalar types
string, int, float, boolean, timestamp, date, json, id.
Field options
| option | meaning |
| ----------------------------------- | -------------------------------------------------------- |
| type | scalar name, or an enum/model name |
| optional | value may be absent (field?) |
| list | value is an array of type |
| id | marks the (single) primary identifier |
| unique | field-level unique → @unique |
| relation | model-typed field is a foreign-key relation (see below) |
| col | Data Connect column dataType (Int64 PKs default to bigserial) |
| default | @default(expr: …) on the DC column |
| literal | pin to one literal value (union discriminant tags) |
| nullable | value may be null (.nullable() / \| null) |
| description | doc comment carried into generated output |
| jsdoc | render the description as a JSDoc block (Firestore fields) |
| constraints → | |
| min / max | numeric bounds |
| minLength / maxLength | string/array length bounds |
| nonempty | non-empty string/array |
| pattern | regex the string must match |
| email / url | string format |
Constraints flow into the Zod schemas, the API request validation, and the class-validator DTOs.
Model options
| option | meaning |
| ------------ | ------------------------------------------------------------------ |
| key | composite primary key field names (else the id: true field) |
| table | table name override (default: snake_case pluralization) |
| gqlName | GraphQL type name override |
| fsName | Firestore-side rename (avoid collisions with table models) |
| directives | multi = each type-level directive on its own line |
| footer | trailing comment block after the closing } in the schema file |
| indexes | composite @index/@unique ({ fields, name?, unique?, expand? } — expand renders args one per line) |
| sql | raw SQL constraints (see SQL migrations) |
Embedded vs relation
A field whose type is another model is one of two things:
- embedded (default) — a nested value object. Nested in TypeScript /
Firestore / Zod; stored as
Any(jsonb) in Data Connect with the logical type preserved and restored by the adapter. - relation (
relation: true) — a foreign-key reference to another table. Data Connect emits a relation (owner: User!, auto-creating the FK column); TypeScript / Firestore / Zod expose the foreign-key id (ownerId).
fields:
profile: { type: ProfileImage } # embedded → Any + adapter
owner: { type: User, relation: true } # relation → owner: User! / ownerIdData Connect operations
Query/mutation operations over a model's table. Emits .gql (over the
auto-generated <table>_insert/update/upsert/delete resolvers, with @auth
directives) plus TS Variables/Result types. Operations are routed per
connector — an operation may target several connectors and is emitted into
each, under <connector>/operations.gql.
defaults:
connectors: [app] # file-level default for operations below
operations:
CreateShop:
type: mutation
model: Shop
action: insert # insert | update | upsert | delete
auth: NO_ACCESS # NO_ACCESS | PUBLIC | USER
connectors: [app, api] # emitted into both connectors
inputs: [type, ownerUser, name, slug, status]
IncrementSeq:
type: mutation
model: Shop
action: update
inc: [projectNoSeq] # → projectNoSeq_update: { inc: 1 }
exprs: { updatedAt: request.time } # → updatedAt_expr: "request.time"
SearchProducts:
type: query
model: Product
auth: PUBLIC
authReason: gated at the app layer # required when auth is PUBLIC
where:
- { field: title, op: contains } # eq | contains | lt | le | gt | ge | ne
- status # shorthand → { field: status, op: eq }
orderBy:
- { field: createdAt, dir: DESC }
limit: 20
select: [id, title, status]
UsageTotals:
type: query
model: UsageLog
where: [{ field: shop, op: eq }]
aggregate:
count: true
sum: [weightedAmount, inputTokens]Relation inputs thread through as owner: { id: $ownerId }, matching Data
Connect's relation-reference form. Composite keys become key: { a: $a, b: $b }.
Fidelity options
Everything below exists to reproduce real hand-written .gql files byte-for-byte:
| option | meaning |
| --- | --- |
| gqlName | rendered operation name (the yml key stays unique across services) |
| footer | trailing # … comment block after the operation |
| raw | emit the operation body verbatim (multi-model queries, _or keyset cursors — field checks are skipped) |
| single: id \| key | single-row lookup (product(id: $id) / product(key: { … })) |
| keyArg / keyVars | update/delete key argument form and variable renames |
| whereAnd: true | render conditions as an _and: [ … ] array |
| entityDir | override the operations/<entity>/ output directory |
| style | formatting hints: signature, args, data, orderBy, auth, key, and, where (inline/multi/compact/bare) |
Inputs support var (variable rename), required (override optionality),
literal (fixed value, no variable), flat (write the FK column directly),
inc (increment write), and asKey (pass a relation as a Model_Key object
variable — $catalogVersion: CatalogVersion_Key). where fields support dotted
paths through relations (review.catalog.id → nested review: { catalog: { id: { eq: … } } }),
literal comparisons, and flat FK reads. select supports nested selections,
aliases, arguments, and reverse joins (comments: reviewComments_on_product).
limit may be a number or a variable ({ var, default?, required? }).
Operations are namespaced per connector: two operations may share a rendered name as long as their connector sets don't overlap.
API endpoints
Model application endpoints in three kind-implied sections — apis: (https /
callable, keyed by REST path), tasks: (Cloud Tasks), and events: (Pub/Sub).
The api-scoped generators — request/response types (api-types),
request-validation Zod (api-validation), class-validator DTOs (api-dto),
and payload contracts (task-payloads) — run for the entries that opt in via
the section defaults or the entry's own generators:. A payload references a
model or declares inline fields; a void response maps to void.
envelopes:
RetryTaskPayload: # RetryTaskPayload<T> = T & { … }
fields:
identifierId: string
opId: string
enqueuedAt: int
tasks: # Cloud Tasks (kind implied)
defaults:
generators: [task-payloads] # section default: applies to entries below
createCatalog:
envelope: RetryTaskPayload # wraps the product data
maxAttempts: 3
request:
fields:
shopId: string
catalogId: string
response:
void: true
events: # Pub/Sub (kind implied)
generateAiResponse:
topic: ai-review-generate-response
timeoutSeconds: 540
generators: [task-payloads]
apis: # https/callable, keyed by REST path
defaults:
generators: [api-types, api-validation]
/shops/{slug}:
operationId: getShopBySlug
kind: callable # https (default) | callable
request: { fields: { slug: { type: string, nonempty: true } } }
response: { model: Shop }
generators: # entry-level wins over section defaults;
- api-types # an inline out overrides the declared template
- { generator: api-dto, out: "src/entries/{path}" }Generator application resolves in two tiers — the section defaults
(apis:/tasks:/events: → defaults.generators) and the entry's own
generators: (which replaces the defaults). apis: keys must be REST paths
(/... or METHOD /... so the same route can appear once per verb) with an
operationId; tasks and Pub/Sub events live in their own sections.
For createCatalog this yields CreateCatalogTaskData, CreateCatalogTaskPayload =
RetryTaskPayload<CreateCatalogTaskData>, and CREATE_CATALOG_MAX_ATTEMPTS, plus the
request types, Zod schema, and DTO.
Firestore projections
Firestore is a denormalized read model, not the same shape as Data Connect.
A projection derives from a DC model (from) and applies the projection
reality automatically:
- relations → resolved string ids (
ownerUser→ownerUserId: z.string()) - timestamps →
z.date(), optional fields →.nullable() pick/omitselect which base fields survive- inline
fieldsadd denormalized data (flattened DAG edges, stringified JSON) extends: [<fragment>…]splices shared fields in (see Fragments below)
firestore:
User:
from: User
collection: users/{userId}
pick: [displayName, photoURL, createdAt, updatedAt, lastLoginAt]
Product:
from: Product
collection: shops/{ws}/.../products/{productNo}
omit: [catalog, log]
fields:
parentProductNos: { type: int, list: true }
linkedCatalogTitle: { type: string, optional: true }Emits Zod schemas + inferred types, mirroring a hand-written Firestore schema
library. (firestore-types is the zero-config entry point for projects that
have not written projections yet: it re-types every model with the Firestore
Timestamp, no firestore: section needed. Once you declare projections,
prefer the firestore generator — it is the one that understands
pick/omit/fsName/denormalized fields.)
With firestore + split: true, fields whose Zod chain is identical to the Data
Connect schema are reused via .pick() — only representation changes are
restated:
export const ShopSchema = DcShopSchema.pick({
type: true, name: true, slug: true, ownerUserId: true, status: true,
}).extend({
createdAt: z.date(),
updatedAt: z.date().nullable(),
})Additional projection options:
- override semantics — fields declared under
fields:usenullable:→.nullable()andoptional:→.optional()explicitly (base-picked fields map DC-optional →.nullable()automatically);default:→.default(…) pickorder is authoritative for the projected field orderhelpers:— verbatim TypeScript (e.g. small accessor functions) emitted into the projection fileinclude: [Model]— host a shared embedded schema in this file even when no projected field references it- FS-only enums render as frozen consts (
REVIEW_STATE+ReviewStateSchema), co-located with the first projection that references them
Fragments (fragments: + extends:)
A fragment is a named group of fields declared once and spliced into
consumers via extends: [<name>…] — the way to share a common field across
many projections (a consistency _meta_ envelope, audit columns, …) without a
tool-imposed convention. The fragment field's name, type, and which docs carry
it are all yours.
The value objects a fragment references can be pinned to a fixed split
output via out (+ optional file) on the model declaration. A pinned value
object is placement-scoped: emitted only at that location (dependency-ordered,
with an index.ts barrel), imported by consumers via that directory, and
excluded from the default typescript/zod barrels — so it lives purely in the
firestore output. This is what lets a shared envelope sit under firestore/_/.
models:
MetaOp:
out: firestore/_ # → firestore/_/_meta_.ts (+ firestore/_/index.ts)
file: _meta_.ts
fields:
id: { type: string }
Meta:
out: firestore/_
file: _meta_.ts
fields:
scope: { type: float, nullable: true }
op: { type: MetaOp, nullable: true }
fragments:
meta:
fields:
_meta_: { type: Meta }
firestore:
Shop:
from: Shop
extends: [meta] # appends `_meta_: MetaSchema` (imported from './_')extends referencing a fragment that is not declared is an UNKNOWN_FRAGMENT
error, not a silent no-op — a typo like extends: [metaa] fails generation
rather than quietly dropping the shared fields.
Discriminated unions
unions:
CatalogOperationDraft:
discriminant: operationType
variants: [AddLinkOperation, CutLinkOperation]Each variant is a model whose operationType field is pinned with literal: so
the output is a valid z.discriminatedUnion('operationType', [...]) plus the TS
union type.
SQL migrations
Constraints Data Connect cannot express — composite foreign keys, CHECK
constraints, extra indexes — are declared per model and emitted as an executable
migrations/constraints.sql:
models:
ProductLink:
key: [catalog, parentProductNo, childProductNo]
sql:
checks:
- "parent_product_no != child_product_no"
foreignKeys:
- { columns: [catalog_id, parent_product_no], references: "products(catalog_id, product_no)" }
indexes:
- { columns: [catalog_id, child_product_no] }Id codecs
For each model with an id field, the id-codecs generator emits typed
encode<Model>Id / decode<Model>Id wrappers (numeric codec for Int64 ids,
string codec otherwise) over generic primitives from a configurable core
module — replacing scattered untyped encodeNumericId calls.
Project config
project:
services:
- { name: shop, database: shop }
- { name: warehouse, database: warehouse }
codebases:
shp: shop
idCodec:
minLength: 8
alphabet: <your-shuffled-sqids-alphabet>
aliases:
"#contracts/*": libs/contracts/src/*services(name/database) feed theFIRESTORE_DATABASESconstants in the splitfirestorebarrel.idCodecpins the Sqids settings as a contract constant; when present, theid-codecsgenerator also emits a self-containedid-core.ts(the encode/decode primitives), so nothing about id encoding lives outside the contract.aliasesmaps#alias/...out-template prefixes to paths relative to the root yml, so imported contracts can target shared libs without relative path gymnastics (longest prefix wins).codebases/ servicelocation+connectorsare consumed by the optionalconfiggenerator (preview), which emits per-servicedataconnect.yaml, per-connectorconnector.yaml, and theFIRESTORE_DATABASES/API_CODEBASESsync constants. It does not yet reproduce every field of a real deployment config (e.g.cloudSqldatasource details) — treat it as a starting point, not a drop-in replacement.
Generators
| name | scope | output | notes |
| ------------------------ | -------- | ----------------------------- | ------------------------------------------------------ |
| typescript | document | types.ts; split: true → types/<table>.ts + barrel | interfaces + const/union enums; split co-locates enums/embedded objects per table |
| zod | document | schemas.ts; split: true → schemas/<table>.ts + barrel | z.object schemas (constraints, z.lazy model refs) |
| data-connect-graphql | document | schema.gql; split: true → schema/<table>.gql | type … @table(name, key); @col/@unique/@index/@default |
| data-connect-operations| document | <connector>/operations.gql + .ts; split: true → <connector>/operations/<entity>/{queries,mutations}.gql | queries/mutations (where ops, orderBy, limit, aggregate, exprs, inc), per connector; split matches the real Data Connect repo layout |
| data-connect-adapter | document | data-connect-adapters.ts | convert Any rows ⇄ logical types |
| firestore-types | document | firestore-types.ts | naive TS types with Firestore Timestamp (all models) |
| firestore | document | firestore.ts; split: true → firestore/<collection>.ts + barrel | Firestore projection Zod schemas (derived, denormalized); extends fragments, pinned value objects under their out (e.g. firestore/_/), DC-schema .pick() reuse, FIRESTORE_DATABASES constants in the barrel |
| api-types | api | api-types.ts | endpoint request/response types |
| api-validation | api | api-validation.ts | endpoint request-validation Zod |
| api-dto | api | <operation>.dto.ts per api | class-validator DTO classes (NestJS dto/ convention) |
| task-payloads | api | task-payloads.ts | envelopes + *TaskData/*TaskPayload + constants |
| sql-migrations | document | migrations/constraints.sql | composite FK / CHECK / index SQL — idempotent (IF NOT EXISTS / duplicate_object guard), safe to re-apply whole |
| id-codecs | document | id-codecs.ts (+ id-core.ts) | typed per-entity id encode/decode wrappers; emits the Sqids primitives when project.idCodec is set |
| unions | document | unions.ts | Zod discriminated unions + TS union types |
| config | document | dataconnect.yaml, connector.yaml, constants.ts | DC configs + sync constants (preview — see Project config) |
Each generator emits nothing when its section is absent, so a small contract
produces a small output. Select a subset with -g.
Scope decides how a generators: declaration takes effect: document
generators run once for the yml that declares them; api generators run only
for the entries that apply them (section defaults.generators → entry
generators:, entry wins).
The Any boundary
Data Connect stores JSON and embedded objects as the Any scalar, erasing the
logical type. The GraphQL generator keeps the logical type in a comment
(metadata: Any # logical: ProductMetadata), and the adapter emits typed
converters that call fromAny/toAny from firebase-contract/runtime:
ProductMetadata → Data Connect: Any → generated adapter → ProductMetadataProgrammatic API
import { compile, generate, generateAll, createDefaultRegistry } from 'firebase-contract'
const { ir, diagnostics } = compile('contract.yml')
// Single target:
const result = generate('contract.yml', {
outDir: 'generated',
generators: ['typescript', 'zod'], // omit for all
write: true,
})
// Every generator declared across the import graph:
const all = generateAll('contract.yml', { write: true })Architecture
YAML → Parser → Import Resolver → Normalized IR → Semantic Validation → Generators- Generators never see YAML. Their only input is the IR (
src/lib/_internal/ir). - Generators are independent of each other and of shared mutable state.
- The CLI is thin and delegates to the compiler; it never calls a generator directly.
- Dependency direction:
cli → compiler → {parser, resolver, ir, validation, generators}.
All implementation lives under src/lib/_internal (imported via the ~internal
alias); the outer layers are thin: src/lib/cli/** (bin), src/lib/modules/**
(public subpath exports, e.g. firebase-contract/runtime), and src/index.ts
(root barrel).
Adding a generator
Implement the Generator interface and register it — no existing code changes:
import { Generator, createDefaultRegistry } from 'firebase-contract'
const openApi: Generator = {
name: 'openapi',
generate: ir => [{ path: 'openapi.json', content: toOpenApi(ir) }],
}
const registry = createDefaultRegistry().register(openApi)Adding a validation rule
Rules are independent (ir) => Diagnostic[] functions; pass a custom set to
validateIr(ir, rules).
