Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-ref-union-collapse-b.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

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`.
61 changes: 58 additions & 3 deletions packages/db/src/query/builder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,8 +478,59 @@ type ResultTypeFromCaseWhen<T> = T extends unknown
? ResultTypeFromSelectValue<T>
: never

// Extract Ref or subobject with a spread or a Ref
type ExtractRef<T> = Prettify<ResultTypeFromSelect<WithoutRefBrand<T>>>
// Extract Ref or subobject with a spread or a Ref.
type ExtractRef<T> = T extends unknown
? IsTrueRef<T> extends true
? T extends RefLeaf<infer U>
? IsNullableRef<T> extends true
? DeepNullable<U>
: U
: never
: Prettify<ResultTypeFromSelect<WithoutRefBrand<T>>>
: never

// A "true" Ref is one that is structurally equivalent to the canonical
// `Ref<U>` shape the query builder produces for its underlying user type
// `U` (taking the ref's own nullability into account). When `T` is a true
// ref, `ExtractRef` can safely return `U` directly; otherwise it must fall
// through to the recursive projection.
//
// Checking only that `T` has "no extra keys" beyond `keyof U` (plus the
// brand/virtual props) is not sufficient. A spread-derived object can keep
// exactly the keys of `U` while:
// - changing a field's type, e.g. `{ ...u, code: u.slug }`, or
// - dropping an optional key, e.g. `const { nickname, ...rest } = u`.
// Both must be recursively projected, not collapsed back to `U`. We
// therefore require strict structural equivalence against the canonical ref
// shape rather than a one-directional key-subset check.
type IsTrueRef<T> =
T extends RefLeaf<infer U>
? RefShapeMatches<T, Ref<U, IsNullableRef<T>>> extends true
? true
: false
: false

// Strict structural equivalence between two ref shapes. Unlike plain
// bidirectional assignability, this is sensitive to *key presence* — an
// object that drops an optional key (e.g. `const { nickname, ...rest } = u`)
// is not considered equal to one that keeps `nickname?`, even though the two
// remain mutually assignable. A direct ref (`u.document`, a union member,
// etc.) is exactly the canonical `Ref` shape and matches here, so it returns
// `U` via the fast path; any spread-derived object differs (changed field
// types, dropped keys, or stripped `readonly` modifiers) and instead falls
// through to the recursive projection, which reconstructs the correct type.
type RefShapeMatches<A, B> =
(<G>() => G extends A ? 1 : 2) extends <G>() => G extends B ? 1 : 2
? true
: false

// Propagate nullable-join semantics into the user-data shape.
type DeepNullable<T> =
T extends Record<string, any>
? IsPlainObject<T> extends true
? { [K in keyof T]: DeepNullable<T[K]> }
: T | undefined
: T | undefined

// Helper type to extract the underlying type from various expression types
type ExtractExpressionType<T> =
Expand Down Expand Up @@ -770,7 +821,11 @@ type VirtualPropsRef<TKey extends string | number = string | number> = {
* select(({ user }) => ({ ...user })) // Returns User type, not Ref types
* ```
*/
export type Ref<T = any, Nullable extends boolean = false> = {
export type Ref<T = any, Nullable extends boolean = false> = T extends unknown
? RefBranch<T, Nullable>
: never

type RefBranch<T, Nullable extends boolean> = {
[K in keyof T]: IsNonExactOptional<T[K]> extends true
? IsNonExactNullable<T[K]> extends true
? // Both optional and nullable
Expand Down
122 changes: 121 additions & 1 deletion packages/db/tests/query/select.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expectTypeOf, test } from 'vitest'
import { createCollection } from '../../src/collection/index.js'
import { createLiveQueryCollection } from '../../src/query/index.js'
import { createLiveQueryCollection, eq } from '../../src/query/index.js'
import { mockSyncCollectionOptions } from '../utils.js'
import { upper } from '../../src/query/builder/functions.js'
import type { OutputWithVirtual } from '../utils.js'
Expand Down Expand Up @@ -109,6 +109,126 @@ describe(`select types`, () => {
expectTypeOf(results).toMatchTypeOf<OutputWithVirtualKeyed<Expected>>()
})

test(`select preserves union types and where works on common keys`, () => {
type ItemDocument =
| { type: 'pdf'; url: string; pages: number }
| { type: 'image'; url: string; width: number; height: number }
| { type: 'legacy'; path: string }

type Item = { id: number; name: string; document: ItemDocument }

const items = createCollection(
mockSyncCollectionOptions<Item>({
id: `union-field-items`,
getKey: (i) => i.id,
initialData: [],
}),
)

// Filtering by a common key of the union should compile,
// and the result should preserve the full discriminated union
const col = createLiveQueryCollection((q) =>
q
.from({ i: items })
.where(({ i }) => eq(i.document.type, `pdf`))
.select(({ i }) => ({
id: i.id,
document: i.document,
})),
)

const result = col.toArray[0]!
expectTypeOf(result.document).toEqualTypeOf<ItemDocument>()
})

test(`select preserves union when nested under another field`, () => {
type Payload =
| { kind: 'text'; body: string }
| { kind: 'binary'; bytes: number; mime: string }

type Envelope = { id: number; payload: { inner: Payload } }

const envelopes = createCollection(
mockSyncCollectionOptions<Envelope>({
id: `nested-union-envelopes`,
getKey: (e) => e.id,
initialData: [],
}),
)

// Selecting a nested object whose field is a discriminated union
// must preserve the union (not collapse to the intersection of keys).
const col = createLiveQueryCollection((q) =>
q.from({ e: envelopes }).select(({ e }) => ({
id: e.id,
payload: e.payload,
})),
)
const r = col.toArray[0]!
expectTypeOf(r.payload).toEqualTypeOf<{ inner: Payload }>()
expectTypeOf(r.payload.inner).toEqualTypeOf<Payload>()
})

test(`spread with a same-key narrower override projects the override type`, () => {
type SpreadUser = {
id: number
code: string | number
slug: string
nickname?: string
}

const spreadUsers = createCollection(
mockSyncCollectionOptions<SpreadUser>({
id: `spread-override-users`,
getKey: (u) => u.id,
initialData: [],
}),
)

const col = createLiveQueryCollection((q) =>
q.from({ u: spreadUsers }).select(({ u }) => ({
narrowed: { ...u, code: u.slug },
})),
)

const result = col.toArray[0]!
// `code` was overridden with `u.slug` (string), so the projected
// field must be `string`, not the original `string | number`.
expectTypeOf(result.narrowed.code).toEqualTypeOf<string>()
})

test(`spread that omits an optional property drops the key`, () => {
type SpreadUser = {
id: number
code: string | number
slug: string
nickname?: string
}

const spreadUsers = createCollection(
mockSyncCollectionOptions<SpreadUser>({
id: `spread-omit-users`,
getKey: (u) => u.id,
initialData: [],
}),
)

const col = createLiveQueryCollection((q) =>
q.from({ u: spreadUsers }).select(({ u }) => {
const { nickname, ...withoutNickname } = u

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Lint failure: unused nickname binding.

ESLint flags nickname as an unused variable (must match /^_/). You can't just rename the binding to _nickname because that would change which property is destructured-out and break the omission semantics this test relies on. Use a rename binding so the key nickname is still removed from the rest while the local name satisfies the rule.

🛠️ Proposed fix
-        const { nickname, ...withoutNickname } = u
+        const { nickname: _nickname, ...withoutNickname } = u
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { nickname, ...withoutNickname } = u
const { nickname: _nickname, ...withoutNickname } = u
🧰 Tools
🪛 ESLint

[error] 218-218: 'nickname' is assigned a value but never used. Allowed unused vars must match /^_/u.

(@typescript-eslint/no-unused-vars)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/tests/query/select.test-d.ts` at line 218, The destructuring in
the select type test is triggering the unused binding lint because `nickname` is
bound but never used, while the rest omission behavior must still exclude the
`nickname` property. Update the destructuring in the `u` assignment to use an
aliasing rename so the property key remains `nickname` but the local variable
name satisfies the `/^_/` rule, and keep the rest object check in
`select.test-d.ts` unchanged semantically.

Source: Linters/SAST tools

return { trimmed: withoutNickname }
}),
)

const result = col.toArray[0]!
// `nickname` was destructured out, so the projected object must
// not reintroduce the key.
type HasNickname = `nickname` extends keyof typeof result.trimmed
? true
: false
expectTypeOf<HasNickname>().toEqualTypeOf<false>()
Comment on lines +223 to +229

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor

Remove unused runtime binding to fix type-only lint warning

The variable result is only consumed in a type position (typeof result.trimmed), triggering an ESLint warning. Access the projected type directly from the collection's return type instead of creating an unused runtime binding.

🛠️ Proposed fix
-    const result = col.toArray[0]!
-    // `nickname` was destructured out, so the projected object must
-    // not reintroduce the key.
-    type HasNickname = `nickname` extends keyof typeof result.trimmed
+    // `nickname` was destructured out, so the projected object must
+    // not reintroduce the key.
+    type HasNickname = `nickname` extends keyof (typeof col.toArray)[number][`trimmed`]
       ? true
       : false
     expectTypeOf<HasNickname>().toEqualTypeOf<false>()

The expression (typeof col.toArray)[number][trimmed] correctly resolves to the trimmed property type of the array elements, eliminating the need for the intermediate result variable.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const result = col.toArray[0]!
// `nickname` was destructured out, so the projected object must
// not reintroduce the key.
type HasNickname = `nickname` extends keyof typeof result.trimmed
? true
: false
expectTypeOf<HasNickname>().toEqualTypeOf<false>()
// `nickname` was destructured out, so the projected object must
// not reintroduce the key.
type HasNickname = `nickname` extends keyof (typeof col.toArray)[number][`trimmed`]
? true
: false
expectTypeOf<HasNickname>().toEqualTypeOf<false>()
🧰 Tools
🪛 ESLint

[error] 223-223: 'result' is assigned a value but only used as a type. Allowed unused vars must match /^_/u.

(@typescript-eslint/no-unused-vars)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/tests/query/select.test-d.ts` around lines 223 - 229, The
`result` binding in `select.test-d.ts` is only used for a type query, so remove
the unused runtime variable and reference the projected element type directly
from `col.toArray` instead. Update the `HasNickname` check to use the `trimmed`
property type from the collection’s return type rather than `typeof
result.trimmed`, keeping the same assertion with `expectTypeOf` while avoiding
the lint warning.

})

test(`nested spread preserves object structure types`, () => {
const users = createUsers()
const col = createLiveQueryCollection((q) => {
Expand Down
Loading