During implementation of your Next.js checkout flow I uncovered a blocking type‑safety issue that prevents order items from saving to Postgres. The offending snippet is shown below notice that productVariantId
and productId
are typed as serial()
in the Drizzle schema, yet we try to pass concrete values when inserting:
(and how I finally fixed it plus a few mini‑drills for you to try)
The Setup: my first try that looked fine
// app/actions/create‑order.ts
"use server"
import { db } from "@/lib/db"
import { orderProduct } from "@/lib/schema"
import { createOrderSchema } from "@/lib/validators"
export async function createOrder(formData: unknown) {
// run‑time validation
const { products, total, status, paymentIntentId } =
createOrderSchema.parse(formData)
// imagine I just created `order` and have its id
const order = [{ id: 42 }]
// the part that explodes
products.map(async ({ productId, quantity, variantId }) => {
await db.insert(orderProduct).values({
productVariantId: variantId, // ← trouble
productId, // ← and trouble
quantity,
orderId: order[0].id,
})
})
}
and the matching Drizzle table using Next.js:
// lib/schema.ts
import { pgTable, serial, integer } from "drizzle-orm/pg-core"
export const orderProduct = pgTable("order_product", {
id: serial("id").primaryKey(),
quantity: integer("quantity").notNull(),
// these *should* reference other tables,
// but I naïvely used `serial` (auto‑increment)
productVariantId: serial("productVariantId")
.notNull()
.references(() => productVariants.id, { onDelete: "cascade" }),
productId: serial("productId")
.notNull()
.references(() => products.id, { onDelete: "cascade" }),
orderId: serial("orderId")
.notNull()
.references(() => orders.id, { onDelete: "cascade" }),
})
Everything built until TypeScript yelled the moment I called .values()
.
The Crash: Type Script angry wall of red
overload matches this call.
Overload 2 of 2, '(values: {...}[]): PgInsertBase<...>', gave the error:
Object literal may only specify known properties, and
'productVariantId' does not exist in type '{ quantity: number ... }[]'.ts(2769)
In plain words: I tried to insert columns the type system thinks I’m not allowed to touch.
Why the compiler was right
serial()
means auto‑increment.
Drizzle marks it as “generated by the database”. Passing a value is forbidden.- Those two IDs are foreign keys, not new IDs.
They should be plaininteger()
columns that reference other tables. - Type mismatch sneaked in.
My Zod schema hadproductId
andvariantId
as string.
The DB expects number.
The Clean‑Room Fix
Patch the table definition
const orderProduct = pgTable("order_product", {
id: serial("id").primaryKey(),
quantity: integer("quantity").notNull(),
// change serial ➜ integer
productVariantId: integer("productVariantId")
.notNull()
.references(() => productVariants.id, { onDelete: "cascade" }),
productId: integer("productId")
.notNull()
.references(() => products.id, { onDelete: "cascade" }),
orderId: integer("orderId")
.notNull()
.references(() => orders.id, { onDelete: "cascade" }),
})
Guard the action with real types
async function createOrder(formData: unknown) {
const parsed = createOrderSchema.parse(formData)
const orderId = (
await db.insert(orders).values(parsed).returning()
)[0].id
// insert every line in parallel and wait for them
await Promise.all(
parsed.products.map(({ productId, variantId, quantity }) =>
db.insert(orderProduct).values({
productId: Number(productId), // cast to number
productVariantId: Number(variantId), // cast to number
quantity,
orderId,
}),
),
)
return { success: true }
}
Why this works
integer()
allows me to supply the IDs.Number()
satisfies both TypeScript and Postgres.Promise.all
keeps inserts concurrent but still waits before returning using Next.js.
Mini‑drills to stretch your skills
Drill | What to try | Why it’s useful |
---|---|---|
1 | Wrap the whole insert in db.transaction(async tx => { ... }) | One failure rolls everything back |
2 | Add optimistic concurrency (version column) | Stops conflicting edits |
3 | Change the Zod schema to z.coerce.number().int() | Removes the need for Number() casts |
4 | Write a Vitest/Jest test that expects ts(2769) with the old code | Locks the bug out forever |
5 | Time map + Promise.all vs. one multi‑row INSERT … VALUES | See which is faster on your setup |
Final thoughts
I stared at that overload for far too long, but the lesson is simple, If an ID is generated somewhere else, use integer()
, not serial()
. Once the schema matched reality, TypeScript stopped fighting me and started helping. The action now runs cleanly and I have a few new habits (and tests) to keep it that way.