Skip to content

Commit 46fccea

Browse files
committed
Narrow generated JSON types
1 parent c04ef75 commit 46fccea

11 files changed

Lines changed: 275 additions & 34 deletions

File tree

‎README.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,21 @@ Generated TypeScript types are compile-time types. The generated code does not
117117
post-process rows or validate runtime values. Runtime parsing belongs to
118118
postgres.js configuration.
119119

120+
PostgreSQL `json` and `jsonb` columns are emitted as `JsonValue` instead of
121+
`any`:
122+
123+
```ts
124+
export type JsonPrimitive = string | number | boolean | null;
125+
export type JsonValue =
126+
| JsonPrimitive
127+
| readonly JsonValue[]
128+
| { readonly [key: string]: JsonValue | undefined };
129+
```
130+
131+
JSON parameters are serialized with `sql.json(...)` in generated queries. For
132+
nullable JSON parameters, JavaScript `null` is sent as SQL `NULL`; use a
133+
non-nullable JSON parameter if you need to write the JSON literal `null`.
134+
120135
## Vercel Notes
121136

122137
The generated code depends on the `postgres` npm package. postgres.js is pure

‎examples/authors/postgresql/query.sql‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ ORDER BY name;
88

99
-- name: CreateAuthor :one
1010
INSERT INTO authors (
11-
name, bio, status
11+
name, bio, status, profile, notes
1212
) VALUES (
13-
$1, $2, $3
13+
$1, $2, $3, $4, $5
1414
)
1515
RETURNING *;
1616

‎examples/authors/postgresql/schema.sql‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,7 @@ CREATE TABLE authors (
44
id BIGSERIAL PRIMARY KEY,
55
name text NOT NULL,
66
bio text,
7-
status author_status NOT NULL DEFAULT 'active'
7+
status author_status NOT NULL DEFAULT 'active',
8+
profile jsonb NOT NULL DEFAULT '{}'::jsonb,
9+
notes jsonb
810
);

‎examples/bun-postgres/src/db/query_sql.ts‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
import type { Sql } from "postgres";
44

5+
export type JsonPrimitive = string | number | boolean | null;
6+
7+
export type JsonValue = JsonPrimitive | readonly JsonValue[] | {
8+
readonly [key: string]: JsonValue | undefined;
9+
};
10+
511
export type AuthorStatus = "active" | "inactive" | "pending";
612

713
export interface GetAuthorArgs {
@@ -13,10 +19,12 @@ export interface GetAuthorRow {
1319
name: string;
1420
bio: string | null;
1521
status: AuthorStatus;
22+
profile: JsonValue;
23+
notes: JsonValue | null;
1624
}
1725

1826
export async function getAuthor(sql: Sql, args: GetAuthorArgs): Promise<GetAuthorRow | null> {
19-
const rows = await sql<GetAuthorRow[]> `SELECT id, name, bio, status FROM authors
27+
const rows = await sql<GetAuthorRow[]> `SELECT id, name, bio, status, profile, notes FROM authors
2028
WHERE id = ${args.id} LIMIT 1`;
2129
return rows[0] ?? null;
2230
}
@@ -26,33 +34,39 @@ export interface ListAuthorsRow {
2634
name: string;
2735
bio: string | null;
2836
status: AuthorStatus;
37+
profile: JsonValue;
38+
notes: JsonValue | null;
2939
}
3040

3141
export async function listAuthors(sql: Sql): Promise<ListAuthorsRow[]> {
32-
return await sql<ListAuthorsRow[]> `SELECT id, name, bio, status FROM authors
42+
return await sql<ListAuthorsRow[]> `SELECT id, name, bio, status, profile, notes FROM authors
3343
ORDER BY name`;
3444
}
3545

3646
export interface CreateAuthorArgs {
3747
name: string;
3848
bio: string | null;
3949
status: AuthorStatus;
50+
profile: JsonValue;
51+
notes: JsonValue | null;
4052
}
4153

4254
export interface CreateAuthorRow {
4355
id: number;
4456
name: string;
4557
bio: string | null;
4658
status: AuthorStatus;
59+
profile: JsonValue;
60+
notes: JsonValue | null;
4761
}
4862

4963
export async function createAuthor(sql: Sql, args: CreateAuthorArgs): Promise<CreateAuthorRow | null> {
5064
const rows = await sql<CreateAuthorRow[]> `INSERT INTO authors (
51-
name, bio, status
65+
name, bio, status, profile, notes
5266
) VALUES (
53-
${args.name}, ${args.bio}, ${args.status}
67+
${args.name}, ${args.bio}, ${args.status}, ${sql.json(args.profile)}, ${args.notes === null ? null : sql.json(args.notes)}
5468
)
55-
RETURNING id, name, bio, status`;
69+
RETURNING id, name, bio, status, profile, notes`;
5670
return rows[0] ?? null;
5771
}
5872

@@ -65,10 +79,12 @@ export interface ListAuthorsByStatusRow {
6579
name: string;
6680
bio: string | null;
6781
status: AuthorStatus;
82+
profile: JsonValue;
83+
notes: JsonValue | null;
6884
}
6985

7086
export async function listAuthorsByStatus(sql: Sql, args: ListAuthorsByStatusArgs): Promise<ListAuthorsByStatusRow[]> {
71-
return await sql<ListAuthorsByStatusRow[]> `SELECT id, name, bio, status FROM authors
87+
return await sql<ListAuthorsByStatusRow[]> `SELECT id, name, bio, status, profile, notes FROM authors
7288
WHERE status = ${args.status}
7389
ORDER BY name`;
7490
}

‎examples/bun-postgres/src/main.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ async function main() {
1515
name: "Seal",
1616
bio: "Kissed from a rose",
1717
status: "active",
18+
profile: { website: "https://example.com", verified: true },
19+
notes: null,
1820
});
1921
if (author === null) {
2022
throw new Error("author not created");

‎examples/node-postgres/src/db/query_sql.ts‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
import type { Sql } from "postgres";
44

5+
export type JsonPrimitive = string | number | boolean | null;
6+
7+
export type JsonValue = JsonPrimitive | readonly JsonValue[] | {
8+
readonly [key: string]: JsonValue | undefined;
9+
};
10+
511
export type AuthorStatus = "active" | "inactive" | "pending";
612

713
export interface GetAuthorArgs {
@@ -13,10 +19,12 @@ export interface GetAuthorRow {
1319
name: string;
1420
bio: string | null;
1521
status: AuthorStatus;
22+
profile: JsonValue;
23+
notes: JsonValue | null;
1624
}
1725

1826
export async function getAuthor(sql: Sql, args: GetAuthorArgs): Promise<GetAuthorRow | null> {
19-
const rows = await sql<GetAuthorRow[]> `SELECT id, name, bio, status FROM authors
27+
const rows = await sql<GetAuthorRow[]> `SELECT id, name, bio, status, profile, notes FROM authors
2028
WHERE id = ${args.id} LIMIT 1`;
2129
return rows[0] ?? null;
2230
}
@@ -26,33 +34,39 @@ export interface ListAuthorsRow {
2634
name: string;
2735
bio: string | null;
2836
status: AuthorStatus;
37+
profile: JsonValue;
38+
notes: JsonValue | null;
2939
}
3040

3141
export async function listAuthors(sql: Sql): Promise<ListAuthorsRow[]> {
32-
return await sql<ListAuthorsRow[]> `SELECT id, name, bio, status FROM authors
42+
return await sql<ListAuthorsRow[]> `SELECT id, name, bio, status, profile, notes FROM authors
3343
ORDER BY name`;
3444
}
3545

3646
export interface CreateAuthorArgs {
3747
name: string;
3848
bio: string | null;
3949
status: AuthorStatus;
50+
profile: JsonValue;
51+
notes: JsonValue | null;
4052
}
4153

4254
export interface CreateAuthorRow {
4355
id: number;
4456
name: string;
4557
bio: string | null;
4658
status: AuthorStatus;
59+
profile: JsonValue;
60+
notes: JsonValue | null;
4761
}
4862

4963
export async function createAuthor(sql: Sql, args: CreateAuthorArgs): Promise<CreateAuthorRow | null> {
5064
const rows = await sql<CreateAuthorRow[]> `INSERT INTO authors (
51-
name, bio, status
65+
name, bio, status, profile, notes
5266
) VALUES (
53-
${args.name}, ${args.bio}, ${args.status}
67+
${args.name}, ${args.bio}, ${args.status}, ${sql.json(args.profile)}, ${args.notes === null ? null : sql.json(args.notes)}
5468
)
55-
RETURNING id, name, bio, status`;
69+
RETURNING id, name, bio, status, profile, notes`;
5670
return rows[0] ?? null;
5771
}
5872

@@ -65,10 +79,12 @@ export interface ListAuthorsByStatusRow {
6579
name: string;
6680
bio: string | null;
6781
status: AuthorStatus;
82+
profile: JsonValue;
83+
notes: JsonValue | null;
6884
}
6985

7086
export async function listAuthorsByStatus(sql: Sql, args: ListAuthorsByStatusArgs): Promise<ListAuthorsByStatusRow[]> {
71-
return await sql<ListAuthorsByStatusRow[]> `SELECT id, name, bio, status FROM authors
87+
return await sql<ListAuthorsByStatusRow[]> `SELECT id, name, bio, status, profile, notes FROM authors
7288
WHERE status = ${args.status}
7389
ORDER BY name`;
7490
}

‎examples/node-postgres/src/main.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ async function main() {
2525
name: "Seal",
2626
bio: "Kissed from a rose",
2727
status: "active",
28+
profile: { website: "https://example.com", verified: true },
29+
notes: null,
2830
});
2931
if (author === null) {
3032
throw new Error("author not created");

‎llms.txt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,9 @@ postgres.js. Current bigint policy emits PostgreSQL `int8`/`bigint`/`bigserial`
4646
as TypeScript `number`, so configure OID 20 parsing as shown when selecting
4747
bigint values. This is intended for values below `Number.MAX_SAFE_INTEGER`.
4848

49+
PostgreSQL `json` and `jsonb` are emitted as generated `JsonValue` aliases
50+
instead of `any`. Scalar JSON parameters are wrapped with `sql.json(...)`; for
51+
nullable JSON parameters, JavaScript `null` is sent as SQL `NULL`.
52+
4953
Current supported surface: PostgreSQL, postgres.js, Node.js/Vercel. Do not rely
5054
on old Bun SQL, MySQL, SQLite, or upstream-sync README instructions.

‎src/app.ts‎

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,55 @@ function enumTypeDecl(name: string, enumDef: Enum): Node {
6666
);
6767
}
6868

69+
function jsonTypeDecls(): Node[] {
70+
return [
71+
factory.createTypeAliasDeclaration(
72+
[factory.createToken(SyntaxKind.ExportKeyword)],
73+
factory.createIdentifier("JsonPrimitive"),
74+
undefined,
75+
factory.createUnionTypeNode([
76+
factory.createKeywordTypeNode(SyntaxKind.StringKeyword),
77+
factory.createKeywordTypeNode(SyntaxKind.NumberKeyword),
78+
factory.createKeywordTypeNode(SyntaxKind.BooleanKeyword),
79+
factory.createLiteralTypeNode(factory.createNull()),
80+
]),
81+
),
82+
factory.createTypeAliasDeclaration(
83+
[factory.createToken(SyntaxKind.ExportKeyword)],
84+
factory.createIdentifier("JsonValue"),
85+
undefined,
86+
factory.createUnionTypeNode([
87+
factory.createTypeReferenceNode(factory.createIdentifier("JsonPrimitive"), undefined),
88+
factory.createTypeOperatorNode(
89+
SyntaxKind.ReadonlyKeyword,
90+
factory.createArrayTypeNode(
91+
factory.createTypeReferenceNode(factory.createIdentifier("JsonValue"), undefined),
92+
),
93+
),
94+
factory.createTypeLiteralNode([
95+
factory.createIndexSignature(
96+
[factory.createToken(SyntaxKind.ReadonlyKeyword)],
97+
[
98+
factory.createParameterDeclaration(
99+
undefined,
100+
undefined,
101+
factory.createIdentifier("key"),
102+
undefined,
103+
factory.createKeywordTypeNode(SyntaxKind.StringKeyword),
104+
undefined,
105+
),
106+
],
107+
factory.createUnionTypeNode([
108+
factory.createTypeReferenceNode(factory.createIdentifier("JsonValue"), undefined),
109+
factory.createKeywordTypeNode(SyntaxKind.UndefinedKeyword),
110+
]),
111+
),
112+
]),
113+
]),
114+
),
115+
];
116+
}
117+
69118
/**
70119
* Convert snake_case to PascalCase
71120
*/
@@ -101,6 +150,7 @@ function codegen(input: GenerateRequest): GenerateResponse {
101150

102151
// Track enums used in this file
103152
const fileEnums = new Set<string>();
153+
let fileUsesJson = false;
104154

105155
for (const query of queries) {
106156
const lowerName = query.name[0].toLowerCase() + query.name.slice(1);
@@ -125,6 +175,9 @@ function codegen(input: GenerateRequest): GenerateResponse {
125175
fileEnums.add(enumName);
126176
usedEnums.add(enumName);
127177
}
178+
if (postgres.isJsonColumn(param.column)) {
179+
fileUsesJson = true;
180+
}
128181
}
129182

130183
try {
@@ -168,6 +221,9 @@ function codegen(input: GenerateRequest): GenerateResponse {
168221
fileEnums.add(enumName);
169222
usedEnums.add(enumName);
170223
}
224+
if (postgres.isJsonColumn(col)) {
225+
fileUsesJson = true;
226+
}
171227
}
172228

173229
try {
@@ -232,7 +288,12 @@ function codegen(input: GenerateRequest): GenerateResponse {
232288
}
233289
}
234290

235-
// Add enum type declarations at the beginning of the file (after imports)
291+
// Add shared JSON and enum type declarations at the beginning of the file (after imports)
292+
const sharedTypeNodes: Node[] = [];
293+
if (fileUsesJson) {
294+
sharedTypeNodes.push(...jsonTypeDecls());
295+
}
296+
236297
const enumNodes: Node[] = [];
237298
for (const enumName of fileEnums) {
238299
const enumDef = enumMap.get(enumName);
@@ -243,7 +304,7 @@ function codegen(input: GenerateRequest): GenerateResponse {
243304

244305
// Insert enum declarations after the preamble (imports)
245306
const preambleLength = postgres.preamble().length;
246-
nodes.splice(preambleLength, 0, ...enumNodes);
307+
nodes.splice(preambleLength, 0, ...sharedTypeNodes, ...enumNodes);
247308

248309
files.push(
249310
new File({

0 commit comments

Comments
 (0)