Generator
The generator reads your database's catalog and writes the types of zapatos/schema. This page lists how to run it, from the command line or from code, every option of zapatosconfig.json and generate(), and the files it writes.
The CLI
sh
npx pgcast [config]
npx zapatos [config]The same program under two names: pgcast, and zapatos, as in Zapatos 6.6.1. It reads the config, then runs generate() with it.
zapatosconfig.json: read from the folder you run it in, if it is there; never from a parent folder. Without it, the config is{}.config: one optional argument, a JSON object, merged over the file key by key:npx pgcast '{"outDir": "./generated"}'keeps the file'sdband replaces itsoutDir.- A key given here replaces the file's whole value, so
'{"db": {"application_name": "codegen"}}'drops the file'sconnectionString. - The file's variables are filled in before the merge: one it names must be set even when the argument replaces its key, or the CLI stops with
Environment variable 'NAME' is not set.
- A key given here replaces the file's whole value, so
- Each string in both, at any depth, has its environment variables filled in (Environment variables).
- There are no flags:
--helpis read as the JSON argument, and fails. Any argument after the first is ignored. - Paths are relative to the folder you run it in:
outDir, and so thezapatosfolder.
json
{
"db": { "connectionString": "{{DATABASE_URL}}" },
"outDir": "./src",
"schemas": { "public": { "include": "*", "exclude": ["seat_bookings"] } }
}A config it can't read stops it before any query, with code 1, Node printing the error and its stack. The error starts with what it was reading:
- the file:
If present, zapatosconfig.json must be a valid JSON file, and all referenced environment variables must exist: - the argument:
If present, the argument to Zapatos must be valid JSON, and all referenced environment variables must exist:
Then it ends with the cause:
| Cause | Ending |
|---|---|
| not valid JSON | JSON.parse's message, which depends on the input and on Node: on Node 22, Unexpected end of JSON input for an empty file, No number after minus sign in JSON at position 1 (line 1 column 2) for --help, Expected double-quoted property name in JSON at position 15 (line 1 column 16) for a trailing comma |
| a variable is not set | Environment variable 'DATABASE_URL' is not set |
A query that fails, the connection included, also ends it with code 1, as generate() says.
Environment variables
{{NAME}} in a string of the config is replaced by process.env.NAME, before anything else runs.
- Anywhere in a string, several per string:
"postgresql://{{PGUSER}}@localhost/films". - Spaces inside the braces are allowed:
{{ DATABASE_URL }}. - In values only, at any depth of objects and arrays; never in keys:
"schemas": { "{{SCHEMA}}": … }is a schema named{{SCHEMA}}. Numbers and booleans pass through. - A variable set to the empty string counts as set, and gives
"". - Only the CLI does this, for the file and for its argument.
generate()takes its config as it is: readprocess.envyourself.
generate()
ts
import { generate, type Config } from 'zapatos/generate';
generate(config: Config): Promise<void>Reads the catalog of config.db and writes the files under config.outDir. config takes the options of zapatosconfig.json, and also functions where the JSON takes true or a name: the three listeners and customTypesTransform.
ts
await generate({
db: { connectionString: process.env.DATABASE_URL },
outDir: './src',
progressListener: line => console.log(`[types] ${line}`),
customTypesTransform: name => `Db_${name}`,
});- It opens a
pg.Poolof its own onconfig.db, and ends it before resolving. - It sends only
SELECTs, oninformation_schemaandpg_catalog: one for the relations of each schema, one for its enums, then two per relation, its columns and its unique indexes, and one for the enums of each other schema a column's type comes from (pg_catalogincluded). - A query that fails, the connection included, prints
*** error <n> ***and the error withconsole.log, then ends the process withprocess.exit(1), as in Zapatos 6.6.1 (#183). On a failed query the promise never settles, so atry/catcharound it does not run. - Other errors reject it: a write that fails (
ENOTDIR: not a directory, mkdir 'afile/zapatos'whenoutDiris a file), a malformed rule. - At run time, a key it doesn't know is ignored: an old
srcMode, a misspeltoutdir. In TypeScript, an object literal with one fails to compile (TS2353, or TS2561 with a suggestion for a near-miss such asoutdir). zapatos/generatealso exportsConfigand the functionsgenerate()is built from (tsForConfig(),definitionForRelationInSchema()and others), with no entry here.
Options
Every option is optional. Each one given replaces its default whole: schemas without public leaves public out.
| Option | Default | What it sets |
|---|---|---|
db | none: pg's own defaults | the connection |
outDir | "." | where the zapatos folder goes |
outExt | ".d.ts" | the extension of every file written |
schemas | { "public": { "include": "*", "exclude": [] } } | the schemas, and the relations of each |
unprefixedSchema | "public" | the schema whose names need no prefix |
columnOptions | {} | columns left out of Insertable and Updatable, or optional |
customTypesTransform | "PgMy_type" | the names of custom types |
schemaJSDoc | true | a JSDoc comment on each relation and column |
customJSONParsingForLargeNumbers | false | the JSONSelectable type of bigint and numeric |
typeEnumsAcrossSchemas | false | the type of an enum of another schema |
enumOrder | "alphabetical" | the order of an enum's labels |
progressListener | false | where each step is reported |
warningListener | true | where warnings go |
debugListener | false | where each query and its result go |
db
pg.ClientConfig: what you'd give new pg.Pool(), a connectionString or host, user, database and the rest. Without it, pg takes its defaults and the PGHOST, PGUSER, PGDATABASE… environment variables.
outDir
string, default ".". The folder that receives the zapatos folder, created if needed: "./src" writes src/zapatos/schema.d.ts. Relative to the current folder.
outExt
string, default ".d.ts". The extension of every file written, the placeholders included: ".ts" writes zapatos/schema.ts, zapatos/custom/PgPoint.ts. Only the names change; the content is the same declare module text. With ".ts", the generator skips the check for the old layout.
schemas
{ [schema]: { include, exclude } }, each of include and exclude "*" or an array of relation names. The schemas to generate, and which relations of each: tables, views, materialised views and foreign tables.
Both keys are required. In generate(), a rule without one fails to compile (TS2741); in the JSON, the CLI stops with TypeError: Cannot read properties of undefined (reading 'indexOf') (#301).
| Rule | Generates |
|---|---|
{ "include": "*", "exclude": [] } | every relation of the schema |
{ "include": ["films", "studios"], "exclude": [] } | films and studios |
{ "include": "*", "exclude": ["seat_bookings"] } | all but seat_bookings |
{ "include": ["films", "studios"], "exclude": ["studios"] } | films: exclude wins |
{ "include": "*", "exclude": "*" } | no relation; the schema's enums, still |
- Names match exactly, case included. A name of
includethat the schema doesn't have generates nothing, and the generator warns, throughwarningListener; a schema the database doesn't have gives an empty namespace, and a warning for each name itsincludelists. With"exclude": "*", nothing is checked. - The relations are those the database role can see: a table or view it has no privilege on is left out, as if it did not exist; a materialised view is always there.
- The schema's enums are always generated, whatever
includeandexcludesay. - The relations of a schema other than
unprefixedSchemaare in its namespace:archive.screenings, itsTabletype'archive.screenings'. - A schema, relation or enum whose name is not a TypeScript identifier (
"box office") is written as it is, andschema.d.tsdoes not compile (TS1005) (#48). A column's name is quoted, and works.
With a second schema, archive, that holds a screenings table of its own:
json
{
"db": { "connectionString": "{{DATABASE_URL}}" },
"schemas": {
"public": { "include": "*", "exclude": [] },
"archive": { "include": "*", "exclude": [] }
}
}A query names its table with the schema, and its rows are s.archive.screenings.JSONSelectable[]:
ts
const past = await db.select('archive.screenings', { film_id: 1000 }).run(pool);Its SQL is select()'s, with the name quoted part by part: to_jsonb("archive"."screenings".*), FROM "archive"."screenings", and the alias "sq_archive.screenings".
unprefixedSchema
string | null, default "public". The schema whose relations are named without their schema: films, its Table type 'films', so db.select('films', …) and "films" in the SQL, which Postgres finds through its search_path.
null: every relation is prefixed,public.filmsits namespace and'public.films'itsTabletype. A query names it so,db.select('public.films', db.all), and the SQL too:FROM "public"."films".db.select('films', db.all)no longer compiles.- Set it to
nullwhen a schema has the name of a relation ofpublic: a schemafilmsbeside the tablefilmsmakes twofilmsnamespaces, which TypeScript merges, andschema.d.tsfails with TS2300,Duplicate identifier 'Table'. - A schema missing from
schemasis allowed: every schema is then prefixed.
columnOptions
{ [table]: { [column]: { insert?, update? } } }. Changes the Insertable and Updatable types of a column, as a trigger that fills it would need; nothing else changes, at run time neither.
json
{
"columnOptions": {
"*": { "booked_at": { "insert": "excluded", "update": "excluded" } },
"films": { "tagline": { "insert": "excluded", "update": "excluded" } }
}
}| Option | Effect on the type |
|---|---|
"insert": "auto", "update": "auto" | none: what the catalog says, the defaults |
"insert": "excluded" | the key leaves Insertable: db.insert('films', { …, tagline: 'x' }) fails to compile (TS2769) |
"insert": "optional" | the key is optional in Insertable, its type unchanged: title?: string | db.Parameter<string> | db.SQLFragment |
"update": "excluded" | the key leaves Updatable: db.update('seat_bookings', { booked_at: … }, …) fails to compile (TS2353) |
- The table is named as its
Tabletype names it:films,archive.screenings, andpublic.filmswhenunprefixedSchemaisnull."films"applies topublic.filmsonly. "*"is every table of every schema. It is the whole key only, never part of a name, and not for columns.- A table's own entry for a column replaces the
"*"entry for that column, as a whole: with"*": { "booked_at": { "insert": "excluded", "update": "excluded" } }and"seat_bookings": { "booked_at": { "update": "excluded" } },booked_atis back inseat_bookings.Insertable. - A table with every column excluded gets
[key: string]: never, as a view that cannot be written to. - Another value, or a misspelt key, is ignored: the column stays
auto.
customTypesTransform
"PgMy_type" | "my_type" | "PgMyType", or in generate() a function (name: string) => string; default "PgMy_type". The TypeScript name of each type the generator leaves to you: a domain, or a type it can't map, such as point. Each one gets a placeholder, and the column the type c.<name>.
| Value | seat_label | point | point[] |
|---|---|---|---|
"PgMy_type" | PgSeat_label | PgPoint | Pg_point |
"my_type" | seat_label | point | _point |
"PgMyType" | PgSeatLabel | PgPoint | Pg_Point |
name => 'Db_' + name | Db_seat_label | Db_point | Db__point |
- The function gets the domain's name, or Postgres's name for the type,
_pointfor an array ofpoint. - An array of a domain or of such a type is a custom type of its own.
- The names are not qualified by schema: the
cnamespace is flat across schemas, so a domainnotesinpublicand one inarchiveare bothPgNotes, with one placeholder for the two.- The generator says so through
warningListener, as for two names that one transform makes one (seat-labelandseat_label), and keeps the name, which an edited placeholder depends on.
- The generator says so through
schemaJSDoc
boolean, default true. A JSDoc comment on each relation (its name and kind) and on each column of every interface: its name, its COMMENT ON COLUMN if it has one, its Postgres type, then a last line on how it is written. Your editor shows it on hover:
ts
/**
* **roles.billing**
*
* Position in the credits: 1 is top billing
* - `int4` in database
* - `NOT NULL`, no default
*/
billing: number;- The type line names a domain with its base type:
`seat_label` (base type: `text`) in database. - The last line is
NOT NULLorNullableand the default (default: `nextval(…)`,no default, oridentity columnforGENERATED BY DEFAULT AS IDENTITY);Generated columnfor a generated orGENERATED ALWAYS AS IDENTITYcolumn;Materialized view columnfor one of a materialised view. - A comment over several lines puts each of them behind a
*, whether it breaks them with\n,\r\nor\r. - Each relation shows its own comments, whatever its kind: Postgres copies none to a partition, an inheriting table or a view, so a view over
rolesshows no comment onbilling. - On a table or a materialised view, a comment holding
*/ends the JSDoc there, soschema.d.tsusually does not compile (TS1131), until #48 escapes it. Its lines after the first are written as Zapatos 6.6.1 writes them, with no*, so one that compiles under 6.6.1, by opening a comment after its*/, still does. On a view, a foreign table or a partitioned table, whose column comments Zapatos 6.6.1 never wrote, such a comment is left out (#48). - A comment, or one of its lines, starting with a JSDoc tag (
@deprecated use lead) becomes that tag in the hover, on every kind of relation, and swallows the generated lines after it (#309).
false writes none, so schema.d.ts is shorter.
customJSONParsingForLargeNumbers
boolean, default false. The JSONSelectable type of a bigint (int8) or numeric column, for a project that parses JSON with enableCustomJSONParsingForLargeNumbers():
| Column | false | true |
|---|---|---|
int8 | number | (number | db.Int8String) |
numeric | number | (number | db.NumericString) |
- The other interfaces don't change:
Selectableisdb.Int8Stringordb.NumericStringeither way. truealso silences the warning such a column gives: "Note: this database has bigint/int8 and/or numeric/decimal columns, for which JSON.parse may lose precision…".
typeEnumsAcrossSchemas
boolean, default false; an opt-in, true by default from 7.0 (Versions). The type of a column whose type is an enum of another schema. false, as in Zapatos 6.6.1, looks the enum up by name in the schema of the column's relation; true looks it up in its own schema, and writes its labels:
sql
CREATE SCHEMA archive;
CREATE TYPE archive.print_format AS ENUM ('nitrate', 'safety', 'digital');
CREATE TYPE archive.crew_job AS ENUM ('projectionist', 'archivist'); -- public has a crew_job too
CREATE TABLE prints (id int PRIMARY KEY, format archive.print_format NOT NULL, formats archive.print_format[], keeper archive.crew_job);Column of prints | false | true |
|---|---|---|
format | c.PgPrint_format, an any placeholder | ('digital' | 'nitrate' | 'safety') |
formats | c.Pg_print_format | null, an any placeholder | ('digital' | 'nitrate' | 'safety')[] | null |
keeper | crew_job | null: the labels of public.crew_job | ('archivist' | 'projectionist') | null |
- The labels are written out, not named: the enum's schema needn't be in
schemas. In the order ofenumOrder, as for every enum. - A domain over such an enum stays a custom type,
c.<name>; its new placeholder gets the labels rather thanany. An existing placeholder is never overwritten. falsenotes, throughwarningListener, the columnstruewould change, each with its type: for a domain, the type under it.
enumOrder
"alphabetical" or "declared", default "alphabetical"; an opt-in, "declared" by default from 7.0 (Versions). The order of an enum's labels. "alphabetical", as in Zapatos 6.6.1, sorts them; "declared" keeps the order the enum declares them in, by which Postgres sorts and compares its values (ORDER BY, <, enum_range()):
sql
CREATE TYPE crew_job AS ENUM ('director', 'writer', 'editor', 'cinematographer', 'composer');| Type | "alphabetical" | "declared" |
|---|---|---|
crew_job | 'cinematographer' | 'composer' | 'director' | 'editor' | 'writer' | 'director' | 'writer' | 'editor' | 'cinematographer' | 'composer' |
every.crew_job | ['cinematographer', 'composer', 'director', 'editor', 'writer'] | ['director', 'writer', 'editor', 'cinematographer', 'composer'] |
- A label added by
ALTER TYPE … ADD VALUE … BEFOREorAFTERtakes its place in that order, asenum_range()gives it. - For TypeScript, only
every.<name>changes: a union is the same type in any order. WithtypeEnumsAcrossSchemas, the labels written out for an enum of another schema, and the new placeholder of a domain over one, take the same order. - Another value stops the generator before any query, and writes nothing:
enumOrder must be 'alphabetical' (the default) or 'declared', not 'declard'. In TypeScript, it fails to compile (TS2322, or TS2820 with a suggestion for a near-miss such as"declard"). "alphabetical"notes, throughwarningListener, each generated enum whose declared order is not alphabetical.
progressListener
boolean, or in generate() a function (s: string) => void; default false. true prints each step with console.log; a function gets each line.
txt
(Re)creating schema folder: src/zapatos/schema.d.ts
Writing generated schema: src/zapatos/schema.d.ts
Custom type or domain declaration file already exists: src/zapatos/custom/PgPoint.d.ts
Custom type or domain declaration file already exists: src/zapatos/custom/PgSeat_label.d.ts
Writing custom types file: src/zapatos/custom/index.d.tswarningListener
boolean, or in generate() a function (s: string) => void; default true, with console.log. false silences:
Writing new custom type or domain placeholder file: src/zapatos/custom/PgPoint.d.ts, once per new placeholder;Note: custom types "archive"."notes" and "public"."notes" are both named PgNotes in TypeScript, so they share one type and one placeholder file, src/zapatos/custom/PgNotes.d.ts. To type them apart, rename one of them in the database.- once per run for each name that two Postgres types share (
customTypesTransform); when their names differ, it adds that acustomTypesTransformfunction can keep them apart;
- once per run for each name that two Postgres types share (
Note: the include entry "Films" of schema "public" matches no relation there, so it generates nothing. Check its spelling and case, that the relation is in schema "public", and that the database role has a privilege on it: include names tables, views, materialised views and foreign tables.- once per run for each entry of an
includelist that its schema doesn't have;
- once per run for each entry of an
- the note on
bigintandnumericcolumns, once per process (customJSONParsingForLargeNumbers); Note: public.prints.format (archive.print_format), public.prints.formats (archive.print_format[]) and public.prints.keeper (archive.crew_job) have a type of another schema, which is looked up by name in the schema of their relation: an enum becomes an `any` custom type, or the enum of the same name there. Set typeEnumsAcrossSchemas: true to look it up in its own schema; it will be the default in pgcast 7.0.- once per run, without
typeEnumsAcrossSchemas, when a column it would change is generated;
- once per run, without
Note: every.crew_job lists the labels of enum public.crew_job in alphabetical order, not in their declared order, by which Postgres sorts and compares them. Set enumOrder: 'declared' to keep that order; it will be the default in pgcast 7.0.- the old layout warning.
debugListener
boolean, or in generate() a function (s: string) => void; default false. Gets each query the generator sends, >>> query <n> >>>, its text and its values, then its result as pg returns it, as JSON, <<< result <n> <<<.
The relations are read concurrently: with several, the results come back interleaved, and a query's number can change from one run to the next, so <n> is what pairs a result with its query.
The unique indexes of tags, with "schemas": { "public": { "include": ["tags"], "exclude": [] } }, cut after its rows:
txt
>>> query 3 >>>
SELECT DISTINCT i.indexname
FROM pg_catalog.pg_indexes i
JOIN pg_catalog.pg_class c ON c.relname = i.indexname
JOIN pg_catalog.pg_index idx ON idx.indexrelid = c.oid AND idx.indisunique
WHERE i.tablename = $1 AND i.schemaname = $2
ORDER BY i.indexname
+ ["tags","public"]
<<< result 3 <<<
{
"command": "SELECT",
"rowCount": 1,
"oid": null,
"rows": [
{
"indexname": "tags_pkey"
}
],
…The result is pg's whole Result object: after rows come fields, its column types, and about seventy lines of pg's own type parsers, the same for every query.
The files it writes
txt
src/zapatos/
├── schema.d.ts written again on each run
└── custom/
├── index.d.ts written again on each run
├── PgPoint.d.ts written once, if it is not there
└── PgSeat_label.d.ts written once, if it is not there| File | Written | Holds |
|---|---|---|
schema.d.ts | on each run | declare module 'zapatos/schema': a namespace per relation, its Table, Selectable, JSONSelectable, Whereable, Insertable, Updatable, UniqueIndex, Column, OnlyCols, SQLExpression and SQL; each enum, and its tuple every.<name>; Schema and the tuple AllSchemas; per schema and over all of them, the unions (Table, Selectable…) and the tuples AllBaseTables, AllForeignTables, AllViews, AllMaterializedViews and AllTablesAndViews; the *ForTable<T> lookups; the version canary |
custom/index.d.ts | on each run, when there is a custom type | an empty declare module 'zapatos/custom' |
custom/<Name>.d.ts | once: never overwritten | a placeholder, export type PgSeat_label = string;, yours to edit |
- A placeholder holds the
Selectabletype of the domain's base type:stringforseat_label, atext;Datefor adatedomain;db.JSONValuefor ajsonorjsonbone, with itsimport type * as db from 'zapatos/db'. A type the generator can't map (point) getsany. How to fill one in: Custom types. - A base type mapped to another
db.*type (int8,numeric,time, ranges…) gives a placeholder that does not compile until you edit it:export type PgTicket_no = db.Int8String;comes with no import, andtscfails with TS2503,Cannot find namespace 'db'(#184). Addimport type * as db from 'zapatos/db';above it. - A domain over an enum is the same:
export type PgGenre_d = genre;fails with TS2304 until you addimport type { genre } from 'zapatos/schema';. - Nothing is deleted: a placeholder whose type is gone stays, and so does
custom/once there is no custom type left. schema.d.tsdeclaresschemaVersionCanary,version: 104. Azapatos/dbthat expects another version fails to compile there: generate again (SchemaVersionCanary).- Each file is a
declare moduleof thezapatos/*names, so yourtsconfig.jsonmust include it:"include": ["src"], withoutDir"./src".
The old layout warning
Zapatos before 3.0 copied its source into yours. After writing, when outExt is not ".ts", the generator looks for that layout: zapatos/schema.ts, a zapatos/src folder, or a file under zapatos/custom that does not end in .d.ts. If it finds one, it warns, through warningListener, with the steps to convert:
txt
*** IMPORTANT: ZAPATOS NO LONGER COPIES ITS SOURCE TO YOUR SOURCE TREE ***So moving from outExt: ".ts" back to ".d.ts" warns too, until you delete the .ts files.
Why the types look as they do, a timestamptz a Date in Selectable and a string in JSONSelectable: The catalog.