Skip to content

feat: Stricter, more expressive type generation (enums, overrides, types-only) + correctness fixes - #73

Open
rtolsma wants to merge 7 commits into
sqlc-dev:mainfrom
rtolsma:strongly-typed-columns
Open

feat: Stricter, more expressive type generation (enums, overrides, types-only) + correctness fixes#73
rtolsma wants to merge 7 commits into
sqlc-dev:mainfrom
rtolsma:strongly-typed-columns

Conversation

@rtolsma

@rtolsma rtolsma commented Aug 22, 2026

Copy link
Copy Markdown

Summary

A series of atomic commits making generated types substantially stricter and more expressive, plus fixes for several open correctness issues. Each commit is independently revertable and states its own verification.

1. Strongly typed columns (cd4421a) — closes #32, addresses #37

  • Enums → string literal unions (automatic). The plugin previously ignored GenerateRequest.catalog, so CREATE TYPE status AS ENUM (...) columns came out as plain string. They now generate:

    export type Status = "active" | "inactive" | "banned";
    export const StatusValues: readonly Status[] = ["active", "inactive", "banned"];

    and every column/parameter of that type references the union.

  • overrides plugin option (opt-in), modeled on sqlc-gen-go's. Match a SQL type by name (db_type — covers Postgres domains, which previously silently collapsed to string) or a single column (column: "table.column"), and emit any TypeScript type expression, with an optional type-only import — enabling branded/nominal ID types and concrete JSON payload types:

    options:
      overrides:
      - db_type: "email"
        ts_type: "Email"
        import: { path: "./brands", name: "Email" }
      - db_type: "user_id"
        ts_type: 'string & { readonly __brand: "user_id" }'
      - column: "users.meta"
        ts_type: "UserMeta"
        import: { path: "./brands", name: "UserMeta" }

    Resolution order: column override → db_type override → schema enum → built-in driver mapping. Nullability (| null) and array (T[]) wrapping compose on top. Implemented as a CustomTypeRegistry (src/customTypes.ts); each driver consults the resolver before its built-in switch, so behavior is identical across all four drivers.

2. Type-only imports (903881e) — fixes #55, fixes #61

All driver preamble imports are now import type (the mysql2 default+named import is split in two, since type-only imports cannot combine them). Generated output now compiles under verbatimModuleSyntax.

3. Valid code for non-identifier column names (74e1a5e) — fixes #70

Columns like "invalid-name" now emit quoted property keys and args["invalid-name"] element access instead of unparseable code.

4. Escape backticks/${/backslashes in query literals (6d4de97) — fixes #40

MySQL backtick-quoted queries (and SQL literals containing ${ or backslashes) no longer corrupt the emitted template literal; the runtime string is byte-identical to the source SQL (verified by evaluating the emitted literal).

5. types_only option (9198dca) — fixes #71

types_only: true emits only Args/Row interfaces, enum unions, and override imports — no driver imports or query functions — for sharing DB types with a frontend.

6. :execlastid respects big number options (e71d7f0) — fixes #22

The mysql2 insert id now follows the same rules as the bigint column mapping: big_number_stringsstring (via String(...)), support_big_numbers alone → number | string, default unchanged.

7. Positional fallback names for unnamed parameters (77e343e) — fixes #29

WHERE uid = ANY($1::uuid[]) previously generated { "": string[] }; unnamed parameters now fall back to arg_N matching their placeholder number. (Array parameter types themselves already worked; this makes them usable.)

Testing

  • tsc --noEmit clean at every commit; wasm built via the documented esbuild + javy flow (javy 1.4.0, sqlc v1.31.1).
  • No unintended output changes: all seven example configs regenerate byte-for-byte identical except where a commit intentionally changes them (type-only imports; node-mysql2 Promise<string>), and those regenerated files are included in the respective commits. The node-pg and node-mysql2 example projects compile with their own tsconfigs.
  • End-to-end tests: Postgres schema with enum (as column and parameter), domains, column-level jsonb override, arrays, nullables, a hyphenated column name, and an ANY($1::uuid[]) parameter; MySQL schema with backtick quoting, ${/backslash literals, and :execlastid under both big-number option combinations. All generated output compiles under tsc --strict, and the pg output additionally under verbatimModuleSyntax.

The README gains sections documenting enum generation, overrides, and types_only.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GFyrFwpJC2eivaBRyNBJqv

rtolsma and others added 7 commits August 22, 2026 01:44
Generate stricter, more expressive TypeScript types in two ways:

1. Enum types declared in the schema (previously ignored — the plugin
   never read the request catalog) are now emitted as string literal
   union types plus a runtime values array, and columns/params of that
   type reference the union:

       export type Status = "active" | "inactive" | "banned";
       export const StatusValues: readonly Status[] = [...];

2. A new `overrides` plugin option (modeled on sqlc-gen-go's overrides)
   maps SQL types — including domains and other custom types — or
   individual `table.column`s to arbitrary TypeScript type expressions,
   with optional type-only imports. This enables branded/nominal types
   and concrete JSON payload types:

       overrides:
       - db_type: "email"
         ts_type: "Email"
         import: { path: "./brands", name: "Email" }
       - db_type: "user_id"
         ts_type: 'string & { readonly __brand: "user_id" }'
       - column: "users.meta"
         ts_type: "UserMeta"
         import: { path: "./brands", name: "UserMeta" }

Resolution order is column override > db_type override > schema enum >
built-in driver mapping. Driver nullability (`| null`) and array (`T[]`)
wrapping still compose on top of resolved custom types. Emitted enum
declarations and imports are deduplicated per generated file and only
included when actually used by that file's queries.

Implemented as a CustomTypeRegistry (src/customTypes.ts) built from the
GenerateRequest catalog and plugin options; each driver accepts the
resolver and consults it before its built-in switch, so behavior is
identical across pg, postgres, mysql2, and better-sqlite3.

Output for schemas without enums or overrides is byte-for-byte
unchanged (verified against all seven example configs), and generated
code for the new features typechecks under `tsc --strict`.

Closes sqlc-dev#32. Addresses sqlc-dev#37.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFyrFwpJC2eivaBRyNBJqv
Driver preambles imported types (QueryArrayConfig/QueryArrayResult from
pg, Sql from postgres, Database from better-sqlite3, mysql/RowDataPacket/
ResultSetHeader from mysql2) as value imports, which fails to compile
under `verbatimModuleSyntax` and forces the runtime driver package to be
a hard dependency of the generated module.

All preamble imports are now `import type`. The mysql2 default+named
import is split into two declarations because a type-only import cannot
combine a default import with named bindings.

Verified the generated output for all drivers compiles under
`tsc --strict` with `verbatimModuleSyntax: true`.

Fixes sqlc-dev#55. Fixes sqlc-dev#61.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFyrFwpJC2eivaBRyNBJqv
Columns like "invalid-name" produced interface properties, object
literal keys, and args property accesses that were not valid
TypeScript, so the generated file failed to parse.

Property names are now emitted as quoted string keys and argument
access falls back to element access (args["invalid-name"]) whenever the
mapped name is not a valid identifier, via new propertyKey/
propertyAccess helpers shared by all four drivers.

Verified with a schema containing a hyphenated column used in both a
SELECT row and an INSERT parameter; output compiles under tsc --strict.
Output for schemas with well-formed names is unchanged (all examples
regenerate byte-for-byte identical).

Fixes sqlc-dev#70.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFyrFwpJC2eivaBRyNBJqv
Query text is emitted inside a template literal, so MySQL
backtick-quoted identifiers (and any backslash or "${" sequence in a
SQL string literal) terminated or corrupted the literal and produced
unparseable or wrong generated code.

The raw template text now escapes backslashes, backticks, and "${"
while the runtime string value stays byte-identical to the original
SQL. Verified by evaluating the emitted literal for a MySQL query using
backtick quoting and a literal containing "${" and backslashes, and
comparing against the source SQL. Existing example output is unchanged.

Fixes sqlc-dev#40.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFyrFwpJC2eivaBRyNBJqv
With `types_only: true`, output contains only the Args/Row interfaces,
enum literal union types, and override imports — no driver imports,
query constants, or query functions. This lets a frontend share the
database types generated for a backend without pulling in a database
driver dependency.

Verified the types-only output compiles standalone under tsc --strict
with verbatimModuleSyntax, including enum unions and override imports.
Default output is unchanged.

Fixes sqlc-dev#71.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFyrFwpJC2eivaBRyNBJqv
The bigint column mapping already honored support_big_numbers /
big_number_strings, but :execlastid always returned Promise<number>,
so AUTO_INCREMENT bigint keys silently lied about their type.

The insert id now follows the same rules as bigint columns:
big_number_strings returns string (converted with String(), since the
driver returns big values as strings while the mysql2 typings declare
insertId as number), support_big_numbers alone returns number | string,
and the default stays number.

Verified against the node-mysql2 example (both options enabled), which
now returns Promise<string>, and the bun-mysql2 example (no options),
which is unchanged; both outputs compile with the examples' tsconfig.

Fixes sqlc-dev#22.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFyrFwpJC2eivaBRyNBJqv
Parameters with no column name — e.g. the cast form
`WHERE uid = ANY($1::uuid[])` — generated an empty property name,
producing an unusable interface member and args[""] access.

Unnamed parameters now fall back to arg_N, numbered to match their
positional placeholder ($1 -> arg_1). Array parameter types themselves
already worked (string[] etc.); this makes them reachable.

Verified with a pg query using = ANY($1::uuid[]): args are now
{ arg_1: string[] } and the output compiles under tsc --strict.
Example outputs are unchanged.

Fixes sqlc-dev#29.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFyrFwpJC2eivaBRyNBJqv
@rtolsma rtolsma changed the title feat: Strongly typed columns via enum literal unions and type overrides feat: Stricter, more expressive type generation (enums, overrides, types-only) + correctness fixes Aug 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment