drizzle-team / drizzle-team/drizzle-orm
[BUG]: `insert().get()` type doesn't reflect `onConflictDoNothing` zero-row case
- 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
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