How to Solve a TypeScript Type Error on My Server Action in Next.js

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

  1. serial() means auto‑increment.
    Drizzle marks it as “generated by the database”. Passing a value is forbidden.
  2. Those two IDs are foreign keys, not new IDs.
    They should be plain integer() columns that reference other tables.
  3. Type mismatch sneaked in.
    My Zod schema had productId and variantId 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

 DrillWhat to tryWhy it’s useful
1Wrap the whole insert in db.transaction(async tx => { ... })One failure rolls everything back
2Add optimistic concurrency (version column)Stops conflicting edits
3Change the Zod schema to z.coerce.number().int()Removes the need for Number() casts
4Write a Vitest/Jest test that expects ts(2769) with the old codeLocks the bug out forever
5Time map + Promise.all vs. one multi‑row INSERT … VALUESSee 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.

Related blog posts