Skip to content

Commit d85baf6

Browse files
dmealingclaude
andcommitted
fix(codegen-ts): SQLite isArray columns emit .\$type<E[]>() for typed JSON
When isArray:true on SQLite, the column maps to text("col",{mode:"json"}) which Drizzle infers as `unknown` without an explicit .\$type<>() annotation. The downstream InferSelectModel then leaves the field `unknown`, forcing consumers to cast back to the actual element type. Add the chain `.\$type<E[]>()` where E is the TS element type for the field's scalar subType (string for STRING/ENUM/CLASS/DATE/TIME/TIMESTAMP/ DECIMAL, number for INT/LONG/CURRENCY/DOUBLE/FLOAT, boolean for BOOLEAN). Object refs leave .\$type unset — consumers layer their own richer schema there. Postgres .array() path is untouched (already element-typed). Surfaced by wizardsofodd.com: with this fix, src/db/generated/Wizard.ts's `bickersWith` becomes `string[]` instead of `unknown`, matching the payload-side Wizard interface and removing the last source of `as unknown as` casts in the consumer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bd78989 commit d85baf6

2 files changed

Lines changed: 60 additions & 1 deletion

File tree

server/typescript/packages/codegen-ts/src/column-mapper.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,36 @@ function isSqlExprDefault(value: string): boolean {
6363
return SQL_EXPR_PATTERNS.some((re) => re.test(value));
6464
}
6565

66+
/**
67+
* For an isArray:true field stored in SQLite as text(...,{mode:"json"}), return
68+
* the TS element type used in the emitted .$type<E[]>() chain. Returns undefined
69+
* when the field's subType doesn't have a stable scalar TS mapping (e.g.,
70+
* field.object — leave the inferred `unknown[]` so the consumer can layer a
71+
* richer schema on top).
72+
*/
73+
function sqliteJsonArrayElementTsType(subType: string): string | undefined {
74+
switch (subType) {
75+
case FIELD_SUBTYPE_STRING:
76+
case FIELD_SUBTYPE_ENUM:
77+
case FIELD_SUBTYPE_CLASS:
78+
case FIELD_SUBTYPE_DATE:
79+
case FIELD_SUBTYPE_TIME:
80+
case FIELD_SUBTYPE_TIMESTAMP:
81+
case FIELD_SUBTYPE_DECIMAL:
82+
return "string";
83+
case FIELD_SUBTYPE_INT:
84+
case FIELD_SUBTYPE_LONG:
85+
case FIELD_SUBTYPE_CURRENCY:
86+
case FIELD_SUBTYPE_DOUBLE:
87+
case FIELD_SUBTYPE_FLOAT:
88+
return "number";
89+
case FIELD_SUBTYPE_BOOLEAN:
90+
return "boolean";
91+
default:
92+
return undefined;
93+
}
94+
}
95+
6696
/** Map a recognized SQL expression to its canonical raw form (uppercase keywords). */
6797
function canonicalizeSqlExpr(value: string): string {
6898
const lower = value.toLowerCase();
@@ -226,6 +256,18 @@ export function mapColumnType(
226256
modifiers.push(".array()");
227257
}
228258

259+
// SQLite stores arrays as JSON in a text column; Drizzle's text(...,{mode:"json"})
260+
// infers the column as `unknown` without a $type<T>() annotation, so consumers
261+
// who pull the inferred type can't see the element type. Emit $type<E[]>()
262+
// so the inferred TS type is element-precise. Postgres uses .array() above
263+
// which is already element-typed by Drizzle.
264+
if (dialect === "sqlite" && isArray) {
265+
const elementType = sqliteJsonArrayElementTsType(subType);
266+
if (elementType !== undefined) {
267+
modifiers.push(`.$type<${elementType}[]>()`);
268+
}
269+
}
270+
229271
if (isRequired(field)) {
230272
modifiers.push(".notNull()");
231273
}

server/typescript/packages/codegen-ts/test/column-mapper.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,29 @@ describe("mapColumnType — SQLite", () => {
5151
expect(spec.dbName).toBe("given_name");
5252
});
5353

54-
test("@isArray on string → text with mode json", () => {
54+
test("@isArray on string → text with mode json and $type<string[]>() chain", () => {
5555
const f = metaField(FIELD_SUBTYPE_STRING, "tags");
5656
f.setIsArray(true);
5757
const spec = mapColumnType(f, "sqlite");
5858
expect(spec.fnName).toBe("text");
5959
expect(spec.fnOptions).toEqual({ mode: "json" });
60+
expect(spec.modifiers).toContain(".$type<string[]>()");
61+
});
62+
63+
test("@isArray on int → text with mode json and $type<number[]>() chain", () => {
64+
const f = metaField(FIELD_SUBTYPE_INT, "scores");
65+
f.setIsArray(true);
66+
const spec = mapColumnType(f, "sqlite");
67+
expect(spec.fnName).toBe("text");
68+
expect(spec.modifiers).toContain(".$type<number[]>()");
69+
});
70+
71+
test("@isArray on boolean → text with mode json and $type<boolean[]>() chain", () => {
72+
const f = metaField(FIELD_SUBTYPE_BOOLEAN, "flags");
73+
f.setIsArray(true);
74+
const spec = mapColumnType(f, "sqlite");
75+
expect(spec.fnName).toBe("text");
76+
expect(spec.modifiers).toContain(".$type<boolean[]>()");
6077
});
6178

6279
test("decimal → text with leadingComment surfacing the precision-fallback", () => {

0 commit comments

Comments
 (0)