Skip to content

Commit 8e26dcf

Browse files
authored
Fix type issue with mutations in transaction (#854)
* Add type test reproducing the problem * Fix type problem * Changeset
1 parent 213da96 commit 8e26dcf

File tree

3 files changed

+108
-1
lines changed

3 files changed

+108
-1
lines changed

.changeset/twelve-pans-act.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@tanstack/electric-db-collection": patch
3+
"@tanstack/db": patch
4+
---
5+
6+
Improve type of mutations in transactions

packages/db/src/types.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,26 @@ export type NonEmptyArray<T> = [T, ...Array<T>]
139139
export type TransactionWithMutations<
140140
T extends object = Record<string, unknown>,
141141
TOperation extends OperationType = OperationType,
142-
> = Transaction<T> & {
142+
> = Omit<Transaction<T>, `mutations`> & {
143+
/**
144+
* We must omit the `mutations` property from `Transaction<T>` before intersecting
145+
* because TypeScript intersects property types when the same property appears on
146+
* both sides of an intersection.
147+
*
148+
* Without `Omit`:
149+
* - `Transaction<T>` has `mutations: Array<PendingMutation<T>>`
150+
* - The intersection would create: `Array<PendingMutation<T>> & NonEmptyArray<PendingMutation<T, TOperation>>`
151+
* - When mapping over this array, TypeScript widens `TOperation` from the specific literal
152+
* (e.g., `"delete"`) to the union `OperationType` (`"insert" | "update" | "delete"`)
153+
* - This causes `PendingMutation<T, OperationType>` to evaluate the conditional type
154+
* `original: TOperation extends 'insert' ? {} : T` as `{} | T` instead of just `T`
155+
*
156+
* With `Omit`:
157+
* - We remove `mutations` from `Transaction<T>` first
158+
* - Then add back `mutations: NonEmptyArray<PendingMutation<T, TOperation>>`
159+
* - TypeScript can properly narrow `TOperation` to the specific literal type
160+
* - This ensures `mutation.original` is correctly typed as `T` (not `{} | T`) when mapping
161+
*/
143162
mutations: NonEmptyArray<PendingMutation<T, TOperation>>
144163
}
145164

packages/electric-db-collection/tests/electric.test-d.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,88 @@ describe(`Electric collection type resolution tests`, () => {
141141
>()
142142
})
143143

144+
it(`should correctly type mutations in transaction handlers when mapping over mutations array`, () => {
145+
const schema = z.object({
146+
id: z.string(),
147+
title: z.string(),
148+
completed: z.boolean(),
149+
})
150+
151+
type TodoType = z.infer<typeof schema>
152+
153+
const options = electricCollectionOptions({
154+
id: `todos`,
155+
schema,
156+
getKey: (item) => item.id,
157+
shapeOptions: {
158+
url: `/api/todos`,
159+
params: { table: `todos` },
160+
},
161+
onDelete: (params) => {
162+
// Direct index access should be correctly typed
163+
expectTypeOf(
164+
params.transaction.mutations[0].original
165+
).toEqualTypeOf<TodoType>()
166+
167+
// Non-null assertion on second element should be correctly typed
168+
expectTypeOf(
169+
params.transaction.mutations[1]!.original
170+
).toEqualTypeOf<TodoType>()
171+
172+
// When mapping over mutations, each mutation.original should be correctly typed
173+
params.transaction.mutations.map((mutation) => {
174+
expectTypeOf(mutation.original).toEqualTypeOf<TodoType>()
175+
return mutation.original.id
176+
})
177+
178+
return Promise.resolve({ txid: 1 })
179+
},
180+
onInsert: (params) => {
181+
// Direct index access should be correctly typed
182+
expectTypeOf(
183+
params.transaction.mutations[0].modified
184+
).toEqualTypeOf<TodoType>()
185+
186+
// When mapping over mutations, each mutation.modified should be correctly typed
187+
params.transaction.mutations.map((mutation) => {
188+
expectTypeOf(mutation.modified).toEqualTypeOf<TodoType>()
189+
return mutation.modified.id
190+
})
191+
192+
return Promise.resolve({ txid: 1 })
193+
},
194+
onUpdate: (params) => {
195+
// Direct index access should be correctly typed
196+
expectTypeOf(
197+
params.transaction.mutations[0].original
198+
).toEqualTypeOf<TodoType>()
199+
expectTypeOf(
200+
params.transaction.mutations[0].modified
201+
).toEqualTypeOf<TodoType>()
202+
203+
// When mapping over mutations, each mutation should be correctly typed
204+
params.transaction.mutations.map((mutation) => {
205+
expectTypeOf(mutation.original).toEqualTypeOf<TodoType>()
206+
expectTypeOf(mutation.modified).toEqualTypeOf<TodoType>()
207+
return mutation.modified.id
208+
})
209+
210+
return Promise.resolve({ txid: 1 })
211+
},
212+
})
213+
214+
// Verify that the handlers are properly typed
215+
expectTypeOf(options.onDelete).parameters.toEqualTypeOf<
216+
[DeleteMutationFnParams<TodoType>]
217+
>()
218+
expectTypeOf(options.onInsert).parameters.toEqualTypeOf<
219+
[InsertMutationFnParams<TodoType>]
220+
>()
221+
expectTypeOf(options.onUpdate).parameters.toEqualTypeOf<
222+
[UpdateMutationFnParams<TodoType>]
223+
>()
224+
})
225+
144226
it(`should infer types from Zod schema through electric collection options to live query`, () => {
145227
// Define a Zod schema for a user with basic field types
146228
const userSchema = z.object({

0 commit comments

Comments
 (0)