Skip to content

Commit 3436682

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 3436682

3 files changed

Lines changed: 88 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: 63 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,68 @@ describe(`select types`, () => {
109109
expectTypeOf(results).toMatchTypeOf<OutputWithVirtualKeyed<Expected>>()
110110
})
111111

112+
test(`select preserves union types and where works on common keys`, () => {
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+
// Filtering by a common key of the union should compile,
129+
// and the result should preserve the full discriminated union
130+
const col = createLiveQueryCollection((q) =>
131+
q
132+
.from({ i: items })
133+
.where(({ i }) => eq(i.document.type, `pdf`))
134+
.select(({ i }) => ({
135+
id: i.id,
136+
document: i.document,
137+
})),
138+
)
139+
140+
const result = col.toArray[0]!
141+
expectTypeOf(result.document).toEqualTypeOf<ItemDocument>()
142+
})
143+
144+
test(`select preserves union when collection type is a union`, () => {
145+
type DocV1 = { version: 1; title: string }
146+
type DocV2 = { version: 2; title: string; subtitle: string }
147+
type Doc = DocV1 | DocV2
148+
149+
const docs = createCollection(
150+
mockSyncCollectionOptions<Doc>({
151+
id: `union-collection`,
152+
getKey: (d) => d.title,
153+
initialData: [],
154+
}),
155+
)
156+
157+
// Without select — union preserved
158+
const col1 = createLiveQueryCollection((q) => q.from({ d: docs }))
159+
const r1 = col1.toArray[0]!
160+
expectTypeOf(r1.version).toEqualTypeOf<1 | 2>()
161+
162+
// With select — union should still be preserved
163+
const col2 = createLiveQueryCollection((q) =>
164+
q.from({ d: docs }).select(({ d }) => ({
165+
version: d.version,
166+
title: d.title,
167+
})),
168+
)
169+
const r2 = col2.toArray[0]!
170+
expectTypeOf(r2.version).toEqualTypeOf<1 | 2>()
171+
expectTypeOf(r2.title).toEqualTypeOf<string>()
172+
})
173+
112174
test(`nested spread preserves object structure types`, () => {
113175
const users = createUsers()
114176
const col = createLiveQueryCollection((q) => {

0 commit comments

Comments
 (0)