Skip to content

Schema and type maps ​

The schema can be written by hand as shown here, generated from a live database (Introspection), derived from DDL (Migrations), or converted from a Kysely, Drizzle or Prisma schema (ORM schemas).

Declaring tables ​

A schema is an interface whose keys are tables. Each column is declared with a helper:

HelperMeaning
Col<"text">NOT NULL, no default
Nullable<"text">NULL allowed
Generated<"int4">has a default (serial, identity, DEFAULT …): optional in INSERT
NullableGenerated<"text">both
Arr<"int4">an array column, int4[] (same as Col<"int4[]">)
Enum<"mood">a column of a user enum type, values listed under $types
Json<T>a jsonb column holding T (Json<T, {}, "json"> for json); see below
View<{ … }>a view: readable like a table, rejected as an INSERT/UPDATE/DELETE target
ts
export interface DB {
  users: {
    id: Generated<"int4">; // serial / identity / DEFAULT: optional on INSERT
    email: Col<"text">; // NOT NULL
    display_name: Nullable<"text">; // NULL allowed
    mood: Enum<"mood", { hasDefault: true }>; // a CREATE TYPE … AS ENUM with a DEFAULT
    created_at: Generated<"timestamptz">;
  };
  posts: {
    id: Generated<"int8">;
    author_id: Col<"int4">;
    title: Col<"text">;
    body: Nullable<"text">;
    tags: Generated<"text[]">;
  };
  user_stats: View<{ user_id: Col<"int4">; posts: Col<"int8"> }>;
  $types: { mood: "happy" | "neutral" | "sad" };
}

Type names are Postgres names. Common spellings are normalized, so integer, int and int4 are the same, as are bigint/int8, boolean/bool, character varying/varchar, double precision/float8 and timestamp with time zone/timestamptz. A name TypeSQL does not know (an extension type or a domain) is kept as is and maps to unknown unless the type map or $types says otherwise.

Typed JSON columns ​

Json<T> declares what a jsonb column holds. It takes the same flags as Col as its second argument ({ nullable: true }, { hasDefault: true }) and "json" as a third for a json column:

ts
type HistoryItem = { at: string; state: { value: number; note?: string } };

interface DB {
  portfolio: {
    id: Generated<"int4">;
    name: Col<"text">;
    history: Json<HistoryItem[], { hasDefault: true }>; // jsonb NOT NULL DEFAULT '[]'
    settings: Json<{ theme: "dark" | "light" }, { nullable: true }>; // jsonb, NULL allowed
  };
}
const sql = createSql<DB>();

The column is still jsonb to the SQL: operators (@>, ?) and functions (jsonb_array_length) work as before. Rows read it as T, and ->, #> and subscripts index into T (an array index gives the element type, a key the property type; see JSON):

ts
const latest = sql(`SELECT id, history -> -1 -> 'state' AS state, settings ->> 'theme' AS theme FROM portfolio`);
// { id: number; state: { value: number; note?: string } | null; theme: string | null }

Parameters written to the column take T. Pass JSON values with sql.json(), which sends them as JSON text with either driver (also as sql.set / sql.values values):

ts
const save = sql(`UPDATE portfolio SET history = ${sql.json(history)} WHERE id = ${sql.param(1)}`);
// ParamsType: [HistoryItem[], number | null]; the history is sent as JSON text by pg and postgres.js

T is trusted, not checked against the database: rows are typed T whatever the column holds.

Enums ​

List enum values under $types; a column declared with Enum<"mood"> then has the type "happy" | "neutral" | "sad", both in rows and as a parameter.

ts
const moods = sql(`SELECT mood, count(*) AS n FROM users GROUP BY mood`);
// { mood: "happy" | "neutral" | "sad"; n: string }

Functions ​

App and extension functions that are not built in go under $functions, with a Sig per function ($functions: { slugify: Sig<["text"], "text"> }). Calling a function that is neither built in nor declared is an error; see Expressions and functions.

Other schemas ​

Tables in a Postgres schema other than public go in a nested object:

ts
interface DB {
  users: { … };                         // public.users, or just users
  auth: { sessions: { token: Col<"uuid">; user_id: Col<"int4"> } }; // auth.sessions
}

System catalogs ​

pg_catalog and information_schema are not part of a schema by default. To query them, intersect your schema with PgCatalog:

ts
const admin = createSql<DB & PgCatalog>();

const connections = admin(`
  SELECT state, count(*)::int AS total
  FROM pg_stat_activity
  WHERE datname = current_database()
  GROUP BY state`);
// { state: string | null; total: number }

const columns = admin(`
  SELECT column_name, data_type, is_nullable
  FROM information_schema.columns
  WHERE table_schema = 'public' AND table_name = $1
  ORDER BY ordinal_position`, "users");
// { column_name: string; data_type: string; is_nullable: string }

PgCatalog covers the relations applications and admin tools usually read, as of PostgreSQL 17:

  • Statistics: pg_stat_activity, pg_stat_database, pg_stat_all_tables, pg_stat_user_tables, pg_stat_all_indexes, pg_stat_user_indexes, pg_statio_user_tables, pg_stat_replication, pg_locks.
  • Catalog views: pg_tables, pg_views, pg_matviews, pg_indexes, pg_sequences, pg_roles, pg_settings, pg_replication_slots, pg_available_extensions.
  • Catalog tables: pg_class, pg_namespace, pg_attribute, pg_type, pg_index, pg_constraint, pg_database, pg_proc, pg_enum, pg_attrdef, pg_description, pg_depend, pg_extension, pg_trigger, pg_inherits, pg_sequence, pg_am.
  • information_schema: schemata, tables, columns, views, table_constraints, key_column_usage, constraint_column_usage, referential_constraints, check_constraints, routines, parameters, sequences, triggers, domains.

How they resolve:

  • pg_catalog relations work unqualified (FROM pg_stat_activity), as in Postgres, and qualified (FROM pg_catalog.pg_class). A relation of your own schema with the same name wins.
  • information_schema relations need the prefix (FROM information_schema.columns).
  • The whole slice is read-only: UPDATE pg_settings … is an error.

Column types are the ones Postgres reports. The information_schema domains are their base types: sql_identifier is name, cardinal_number is int4, character_data and yes_or_no are varchar, time_stamp is timestamptz. System types without a driver parser (xid, pg_lsn, oidvector, int2vector, aclitem, pg_node_tree) are string. Under node-postgres, aclitem[] and "char"[] columns (relacl, proargmodes) come back as the raw {…} text, and PgTypeMap types them as string.

Postgres does not record whether a view column can be NULL. A view column is NOT NULL in PgCatalog only when its definition proves it: for example pg_tables.tablename is string, while pg_tables.schemaname (from a LEFT JOIN) is string | null. The slice is generated from a live server by scripts/pg-catalog-from-pg.mjs, and the test suite checks it against Postgres with both drivers.

Type maps ​

The schema says what Postgres returns; the type map says what the driver turns it into. int8 and numeric, for example, are strings under node-postgres because a JavaScript number cannot hold every value. Pick the map that matches your driver by importing createSql from its adapter:

Postgres type@typesql/pg@typesql/postgres@typesql/postgres (createBigIntSql)
int2, int4, float4, float8numbernumbernumber
int8 (also count(*))stringstringbigint
numericstringstringstring
text, varchar, uuidstringstringstring
boolbooleanbooleanboolean
date, timestamp, timestamptzDateDateDate
json, jsonbunknown (typed when its shape is known)unknownunknown
byteaBufferBufferBuffer
T[]element type []element type []element type []

createSql from @typesql/core itself uses DefaultTypeMap, which matches node-postgres. A custom map is any object type from Postgres type names to TypeScript types:

ts
import { createSql, type DefaultTypeMap } from "@typesql/core";

interface MyMap extends Omit<DefaultTypeMap, "int8"> {
  int8: number; // you registered an int8 parser that returns numbers
  "int8[]": number[]; // an exact entry wins over the element-wise array mapping
}
const sql = createSql<DB, MyMap>();
const counts = sql("SELECT author_id, count(*) AS n FROM posts GROUP BY author_id");
// { author_id: number; n: number }

The full mapping for each driver, including intervals, geometric types and arrays, is on its adapter page: node-postgres, postgres.js and Prisma.

Parameter types ​

A parameter accepts its type's row type and, on top of it, the entry of the map's $params key: what the driver can serialize for that type. A map without $params gets DefaultParamTypeMap, which both pg and postgres.js handle:

Postgres typeParameters also accept
int8, numeric, moneynumber | bigint | string
date, timestamp, timestamptzDate | string
intervalstring
byteaUint8Array

So a map that types rows with a custom parser keeps the driver's input types: with timestamptz: string (a parser that formats timestamps) parameters still accept a Date. PgTypeMap and PostgresJsTypeMap declare their own $params (PgParamTypeMap, PostgresJsParamTypeMap; postgres.js also takes epoch milliseconds for dates). To change what parameters accept, set $params; it replaces the default, so extend DefaultParamTypeMap (or the adapter's) to keep the rest:

ts
import type { DefaultParamTypeMap } from "@typesql/core";

/** Your own value class; node-postgres sends it through `toPostgres()`. */
declare class Instant {
  toPostgres(): string;
}
interface AppParams extends Omit<DefaultParamTypeMap, "timestamptz"> {
  timestamptz: Date | string | Instant;
}
interface AppMap extends Omit<DefaultTypeMap, "timestamptz"> {
  timestamptz: string; // a parser that formats timestamps
  $params: AppParams;
}
const app = createSql<DB, AppMap>();
const recent = app("SELECT id, created_at FROM users WHERE created_at > $1", new Date());
// Query<{ id: number; created_at: string }, [string | Date | Instant | null]>