Skip to content

Commit 99e9afe

Browse files
kevin-dpautofix-ci[bot]claude
authored
fix(db): preserve discriminated union types through .select() (#1511) (#1597)
* fix(db): preserve discriminated union types through .select() (#1511) Distribute Ref<T> over its T parameter so a discriminated union field no longer has its keys collapsed by the mapped-type keyof. Reshape ExtractRef<T> to distinguish a real branded Ref (return the underlying user type U directly) from a spread-produced inline object (still projected via ResultTypeFromSelect). Add DeepNullable<U> so the Nullable=true flag (from left/right/full joins) keeps propagating | undefined into every leaf, preserving prior join-test behavior. Fixes the issue both at the top level and when the union field is nested inside another selected object. * ci: apply automated fixes * fix(db): tighten true-Ref detection to a strict structural match The new `IsTrueRef` fast path only checked that a ref had no keys beyond `keyof U` (plus the brand/virtual props). That key-subset check let spread-derived objects that keep the same key set but change a field's type (`{ ...u, code: u.slug }`) or drop an optional key (`const { nickname, ...rest } = u`) be classified as a true `Ref` and collapsed back to `U`, discarding the projection. Require strict structural equivalence against the canonical `Ref<U>` shape instead, so only genuine refs take the fast path and any spread-derived object falls through to `ResultTypeFromSelect`. Adds regression tests for both cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: kevin-dp <kevin-dp@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 928afa4 commit 99e9afe

3 files changed

Lines changed: 184 additions & 4 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 `.select()` collapsing discriminated-union fields to the intersection of common keys (#1511). `Ref<T>` now distributes over `T` so `keyof (A | B | C)` no longer reduces the union to its common keys, and `ExtractRef<T>` now distinguishes a real branded `Ref` (where the underlying user type `U` can be returned directly) from a spread-produced inline object (which still needs to be projected through `ResultTypeFromSelect`). This preserves discriminated unions both when the field is selected at the top level and when the field is nested inside another selected object. The real-`Ref` detection uses a strict structural equivalence against the canonical `Ref<U>` shape, so spread-derived objects that keep the same keys but change a field's type (e.g. `{ ...u, code: u.slug }`) or drop an optional key (e.g. `const { nickname, ...rest } = u`) are projected through `ResultTypeFromSelect` instead of being collapsed back to `U`.

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

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -478,8 +478,59 @@ type ResultTypeFromCaseWhen<T> = T extends unknown
478478
? ResultTypeFromSelectValue<T>
479479
: never
480480

481-
// Extract Ref or subobject with a spread or a Ref
482-
type ExtractRef<T> = Prettify<ResultTypeFromSelect<WithoutRefBrand<T>>>
481+
// Extract Ref or subobject with a spread or a Ref.
482+
type ExtractRef<T> = T extends unknown
483+
? IsTrueRef<T> extends true
484+
? T extends RefLeaf<infer U>
485+
? IsNullableRef<T> extends true
486+
? DeepNullable<U>
487+
: U
488+
: never
489+
: Prettify<ResultTypeFromSelect<WithoutRefBrand<T>>>
490+
: never
491+
492+
// A "true" Ref is one that is structurally equivalent to the canonical
493+
// `Ref<U>` shape the query builder produces for its underlying user type
494+
// `U` (taking the ref's own nullability into account). When `T` is a true
495+
// ref, `ExtractRef` can safely return `U` directly; otherwise it must fall
496+
// through to the recursive projection.
497+
//
498+
// Checking only that `T` has "no extra keys" beyond `keyof U` (plus the
499+
// brand/virtual props) is not sufficient. A spread-derived object can keep
500+
// exactly the keys of `U` while:
501+
// - changing a field's type, e.g. `{ ...u, code: u.slug }`, or
502+
// - dropping an optional key, e.g. `const { nickname, ...rest } = u`.
503+
// Both must be recursively projected, not collapsed back to `U`. We
504+
// therefore require strict structural equivalence against the canonical ref
505+
// shape rather than a one-directional key-subset check.
506+
type IsTrueRef<T> =
507+
T extends RefLeaf<infer U>
508+
? RefShapeMatches<T, Ref<U, IsNullableRef<T>>> extends true
509+
? true
510+
: false
511+
: false
512+
513+
// Strict structural equivalence between two ref shapes. Unlike plain
514+
// bidirectional assignability, this is sensitive to *key presence* — an
515+
// object that drops an optional key (e.g. `const { nickname, ...rest } = u`)
516+
// is not considered equal to one that keeps `nickname?`, even though the two
517+
// remain mutually assignable. A direct ref (`u.document`, a union member,
518+
// etc.) is exactly the canonical `Ref` shape and matches here, so it returns
519+
// `U` via the fast path; any spread-derived object differs (changed field
520+
// types, dropped keys, or stripped `readonly` modifiers) and instead falls
521+
// through to the recursive projection, which reconstructs the correct type.
522+
type RefShapeMatches<A, B> =
523+
(<G>() => G extends A ? 1 : 2) extends <G>() => G extends B ? 1 : 2
524+
? true
525+
: false
526+
527+
// Propagate nullable-join semantics into the user-data shape.
528+
type DeepNullable<T> =
529+
T extends Record<string, any>
530+
? IsPlainObject<T> extends true
531+
? { [K in keyof T]: DeepNullable<T[K]> }
532+
: T | undefined
533+
: T | undefined
483534

484535
// Helper type to extract the underlying type from various expression types
485536
type ExtractExpressionType<T> =
@@ -770,7 +821,11 @@ type VirtualPropsRef<TKey extends string | number = string | number> = {
770821
* select(({ user }) => ({ ...user })) // Returns User type, not Ref types
771822
* ```
772823
*/
773-
export type Ref<T = any, Nullable extends boolean = false> = {
824+
export type Ref<T = any, Nullable extends boolean = false> = T extends unknown
825+
? RefBranch<T, Nullable>
826+
: never
827+
828+
type RefBranch<T, Nullable extends boolean> = {
774829
[K in keyof T]: IsNonExactOptional<T[K]> extends true
775830
? IsNonExactNullable<T[K]> extends true
776831
? // Both optional and nullable

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

Lines changed: 121 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,126 @@ 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 nested under another field`, () => {
145+
type Payload =
146+
| { kind: 'text'; body: string }
147+
| { kind: 'binary'; bytes: number; mime: string }
148+
149+
type Envelope = { id: number; payload: { inner: Payload } }
150+
151+
const envelopes = createCollection(
152+
mockSyncCollectionOptions<Envelope>({
153+
id: `nested-union-envelopes`,
154+
getKey: (e) => e.id,
155+
initialData: [],
156+
}),
157+
)
158+
159+
// Selecting a nested object whose field is a discriminated union
160+
// must preserve the union (not collapse to the intersection of keys).
161+
const col = createLiveQueryCollection((q) =>
162+
q.from({ e: envelopes }).select(({ e }) => ({
163+
id: e.id,
164+
payload: e.payload,
165+
})),
166+
)
167+
const r = col.toArray[0]!
168+
expectTypeOf(r.payload).toEqualTypeOf<{ inner: Payload }>()
169+
expectTypeOf(r.payload.inner).toEqualTypeOf<Payload>()
170+
})
171+
172+
test(`spread with a same-key narrower override projects the override type`, () => {
173+
type SpreadUser = {
174+
id: number
175+
code: string | number
176+
slug: string
177+
nickname?: string
178+
}
179+
180+
const spreadUsers = createCollection(
181+
mockSyncCollectionOptions<SpreadUser>({
182+
id: `spread-override-users`,
183+
getKey: (u) => u.id,
184+
initialData: [],
185+
}),
186+
)
187+
188+
const col = createLiveQueryCollection((q) =>
189+
q.from({ u: spreadUsers }).select(({ u }) => ({
190+
narrowed: { ...u, code: u.slug },
191+
})),
192+
)
193+
194+
const result = col.toArray[0]!
195+
// `code` was overridden with `u.slug` (string), so the projected
196+
// field must be `string`, not the original `string | number`.
197+
expectTypeOf(result.narrowed.code).toEqualTypeOf<string>()
198+
})
199+
200+
test(`spread that omits an optional property drops the key`, () => {
201+
type SpreadUser = {
202+
id: number
203+
code: string | number
204+
slug: string
205+
nickname?: string
206+
}
207+
208+
const spreadUsers = createCollection(
209+
mockSyncCollectionOptions<SpreadUser>({
210+
id: `spread-omit-users`,
211+
getKey: (u) => u.id,
212+
initialData: [],
213+
}),
214+
)
215+
216+
const col = createLiveQueryCollection((q) =>
217+
q.from({ u: spreadUsers }).select(({ u }) => {
218+
const { nickname, ...withoutNickname } = u
219+
return { trimmed: withoutNickname }
220+
}),
221+
)
222+
223+
const result = col.toArray[0]!
224+
// `nickname` was destructured out, so the projected object must
225+
// not reintroduce the key.
226+
type HasNickname = `nickname` extends keyof typeof result.trimmed
227+
? true
228+
: false
229+
expectTypeOf<HasNickname>().toEqualTypeOf<false>()
230+
})
231+
112232
test(`nested spread preserves object structure types`, () => {
113233
const users = createUsers()
114234
const col = createLiveQueryCollection((q) => {

0 commit comments

Comments
 (0)