drizzle-team / drizzle-team/drizzle-orm

[BUG]: `insert().get()` type doesn't reflect `onConflictDoNothing` zero-row case

Open
#6,107 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
35.8k
Forks
1.6k
Avg merge
2d 7h
Merged PRs (30d)
4

Description

### Describe the Bug

`select().get()` was fixed in #595 (closing #565) to type as `T | undefined`, since a `WHERE` clause can legitimately match zero rows at runtime.

`insert().returning().get()` has the exact same runtime shape — the underlying driver call (`stmt.get()` in better-sqlite3, `node:sqlite`, etc.) returns `undefined` when the statement affects zero rows — but the type is still just `TReturning`, never `TReturning | undefined`:

```ts
// sqlite-core/async/insert.d.ts
get: T['_']['returning'] extends undefined
? DrizzleTypeError<'.get() cannot be used without .returning()'>
: T['_']['returning']; // <- no `| undefined`
```

`.all()` has the analogous gap: it's correctly typed as `TReturning[]` (never lies about that), but chaining `.onConflictDoNothing()` or a conditional `.onConflictDoUpdate({ setWhere })` doesn't communicate anywhere in the type that the array can now legitimately be empty, so consumers indexing into it (e.g. under `noUncheckedIndexedAccess`) have no type signal that the previously-safe index access is no longer safe.

This is a real, reachable runtime case, not a hypothetical:

```ts
const inserted = db.insert(users)
.values({ email })
.onConflictDoNothing()
.returning()
.get();

inserted.id; // typed as safe, but `inserted` is `undefined` at runtime on conflict
```

#2474 and #3145 both confirm the zero-row-on-conflict runtime behavior is intended/known, but neither addresses the type. #3145 is tracked purely as a docs issue.

### Expected behavior

Same treatment `select().get()` got in #595: when the insert (or update/delete) chain includes `.onConflictDoNothing()` or a conditional `.onConflictDoUpdate()`, `.get()` should type as `TReturning | undefined`, and `.all()`/indexing into it should reflect that the array can be empty.

This was explicitly flagged as unresolved by @AndriiSherman in #595 itself:

> Now, this is overriding the type for `.insert()`, `.update()`, and `.delete()` methods and cannot tell if this is accurate or not. Thoughts?

This issue is a follow-up to close that loop for `insert` (and likely `update`/`delete` under similar conflict-tolerant paths, if applicable).

### Environment

- `drizzle-orm` (reproduced against `1.0.0-rc.4`, but present since the original `.get()` insert typing was introduced)
- Driver: `node:sqlite` (`drizzle-orm/node-sqlite`), also applies to `better-sqlite3`, `libsql`

### Related

- #565 (fixed, select only)
- #595 (the fix PR — scope limited to select, question left open)
- #2474 (runtime behavior, closed not planned)
- #3145 (docs-only ask, open)

Contributor guide

Open the contributing guide

Research direction

Start with sqlite-core/async/insert.d.ts and compare the select().get() typing change from #595; trace the insert().returning().get()/all() chain and conflict methods across the mentioned drivers. Done means conflict-tolerant get() exposes undefined and array/indexing types reflect zero rows for applicable insert, update, and delete paths.

Written by the indexing model from the issue text.

Assessment

Tech stack
sqlite, typescript
Domain
databases
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.