Skip to content

Commit d56337e

Browse files
committed
fix(db): preserve union types in select result extraction
When a collection field is a union of object types (e.g., `{ type: 'pdf'; url: string } | { type: 'image'; width: number }`), selecting that field via `.select()` collapses the union into a single type containing only the intersection of keys. This happens because `Ref<T>` uses a mapped type `[K in keyof T]`, and when `T[K]` is a union of objects, `IsPlainObject` returns true (it distributes), causing `Ref` to recurse into the union. But `keyof (A | B | C)` only yields common keys, destroying the discriminated union.
1 parent c28ab1e commit d56337e

3 files changed

Lines changed: 114 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/db': patch
3+
---
4+
5+
fix: preserve discriminated union types when selecting object fields via `.select()`

packages/db/src/query/builder/types.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,9 +322,16 @@ export type ResultTypeFromSelect<TSelectObject> =
322322
: // includes subquery (bare QueryBuilder) — produces a child Collection
323323
TSelectObject[K] extends QueryBuilder<infer TChildContext>
324324
? Collection<GetResult<TChildContext>>
325-
: // Ref (full object ref or spread with RefBrand) - recursively process properties
325+
: // Ref (full object ref or spread with RefBrand)
326326
TSelectObject[K] extends Ref<infer _T>
327-
? ExtractRef<TSelectObject[K]>
327+
? // When the branded type is a union, extract it directly to
328+
// preserve the discriminated union. ExtractRef would collapse
329+
// it via keyof/Prettify which only yield common keys.
330+
true extends IsUnion<ExtractRefBrand<TSelectObject[K]>>
331+
? IsNullableRef<TSelectObject[K]> extends true
332+
? ExtractRefBrand<TSelectObject[K]> | undefined
333+
: ExtractRefBrand<TSelectObject[K]>
334+
: ExtractRef<TSelectObject[K]>
328335
: // RefLeaf (simple property ref like user.name)
329336
TSelectObject[K] extends RefLeaf<infer T>
330337
? IsNullableRef<TSelectObject[K]> extends true
@@ -372,6 +379,9 @@ export type SelectResult<TSelect> =
372379
? ResultTypeFromSelect<TSelect>
373380
: ResultTypeFromSelectValue<TSelect>
374381

382+
// Extract the raw type stored in a RefLeaf's brand
383+
type ExtractRefBrand<T> = T extends RefLeaf<infer U> ? U : never
384+
375385
// Extract Ref or subobject with a spread or a Ref
376386
type ExtractRef<T> = Prettify<ResultTypeFromSelect<WithoutRefBrand<T>>>
377387

@@ -1068,6 +1078,14 @@ type IsPlainObject<T> = T extends unknown
10681078

10691079
type IsAny<T> = 0 extends 1 & T ? true : false
10701080

1081+
// Detects whether T is a union type (e.g., A | B | C returns true, A returns false)
1082+
type IsUnion<T, U = T> = T extends unknown
1083+
? [U] extends [T]
1084+
? false
1085+
: true
1086+
: false
1087+
1088+
10711089
/**
10721090
* JsBuiltIns - List of JavaScript built-ins
10731091
*/

packages/db/tests/query/select.test-d.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expectTypeOf, test } from 'vitest'
22
import { createCollection } from '../../src/collection/index.js'
3-
import { createLiveQueryCollection } from '../../src/query/index.js'
3+
import { createLiveQueryCollection, eq } from '../../src/query/index.js'
44
import { mockSyncCollectionOptions } from '../utils.js'
55
import { upper } from '../../src/query/builder/functions.js'
66
import type { OutputWithVirtual } from '../utils.js'
@@ -109,6 +109,94 @@ describe(`select types`, () => {
109109
expectTypeOf(results).toMatchTypeOf<OutputWithVirtualKeyed<Expected>>()
110110
})
111111

112+
test(`select preserves union types on object fields`, () => {
113+
type ItemDocument =
114+
| { type: 'pdf'; url: string; pages: number }
115+
| { type: 'image'; url: string; width: number; height: number }
116+
| { type: 'legacy'; path: string }
117+
118+
type Item = { id: number; name: string; document: ItemDocument }
119+
120+
const items = createCollection(
121+
mockSyncCollectionOptions<Item>({
122+
id: `union-field-items`,
123+
getKey: (i) => i.id,
124+
initialData: [],
125+
}),
126+
)
127+
128+
const col = createLiveQueryCollection((q) =>
129+
q.from({ i: items }).select(({ i }) => ({
130+
id: i.id,
131+
document: i.document,
132+
})),
133+
)
134+
135+
const result = col.toArray[0]!
136+
expectTypeOf(result.document).toEqualTypeOf<ItemDocument>()
137+
})
138+
139+
test(`where clause works on common keys of union fields`, () => {
140+
type ItemDocument =
141+
| { type: 'pdf'; url: string; pages: number }
142+
| { type: 'image'; url: string; width: number; height: number }
143+
| { type: 'legacy'; path: string }
144+
145+
type Item = { id: number; name: string; document: ItemDocument }
146+
147+
const items = createCollection(
148+
mockSyncCollectionOptions<Item>({
149+
id: `union-where-items`,
150+
getKey: (i) => i.id,
151+
initialData: [],
152+
}),
153+
)
154+
155+
// Filtering by a common key of the union should compile
156+
const col = createLiveQueryCollection((q) =>
157+
q
158+
.from({ i: items })
159+
.where(({ i }) => eq(i.document.type, `pdf`))
160+
.select(({ i }) => ({
161+
id: i.id,
162+
document: i.document,
163+
})),
164+
)
165+
166+
const result = col.toArray[0]!
167+
expectTypeOf(result.document).toEqualTypeOf<ItemDocument>()
168+
})
169+
170+
test(`select preserves union when collection type is a union`, () => {
171+
type DocV1 = { version: 1; title: string }
172+
type DocV2 = { version: 2; title: string; subtitle: string }
173+
type Doc = DocV1 | DocV2
174+
175+
const docs = createCollection(
176+
mockSyncCollectionOptions<Doc>({
177+
id: `union-collection`,
178+
getKey: (d) => d.title,
179+
initialData: [],
180+
}),
181+
)
182+
183+
// Without select — union preserved
184+
const col1 = createLiveQueryCollection((q) => q.from({ d: docs }))
185+
const r1 = col1.toArray[0]!
186+
expectTypeOf(r1.version).toEqualTypeOf<1 | 2>()
187+
188+
// With select — union should still be preserved
189+
const col2 = createLiveQueryCollection((q) =>
190+
q.from({ d: docs }).select(({ d }) => ({
191+
version: d.version,
192+
title: d.title,
193+
})),
194+
)
195+
const r2 = col2.toArray[0]!
196+
expectTypeOf(r2.version).toEqualTypeOf<1 | 2>()
197+
expectTypeOf(r2.title).toEqualTypeOf<string>()
198+
})
199+
112200
test(`nested spread preserves object structure types`, () => {
113201
const users = createUsers()
114202
const col = createLiveQueryCollection((q) => {

0 commit comments

Comments
 (0)