Skip to content

fix(db): preserve discriminated union types through .select() (#1511)#1597

Open
kevin-dp wants to merge 3 commits into
TanStack:mainfrom
kevin-dp:approach-b-1511
Open

fix(db): preserve discriminated union types through .select() (#1511)#1597
kevin-dp wants to merge 3 commits into
TanStack:mainfrom
kevin-dp:approach-b-1511

Conversation

@kevin-dp

@kevin-dp kevin-dp commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #1511.

Problem

.select() collapses a discriminated-union field (e.g. { type: 'pdf'; url: string } | { type: 'image'; width: number }) to the intersection of common keys. Two failing-on-main type tests in packages/db/tests/query/select.test-d.ts demonstrate both the shallow case and the nested case (union inside payload: { inner: Payload }).

Root cause

Two compounding issues in packages/db/src/query/builder/types.ts:

  1. Ref<T, Nullable> is a non-distributive mapped type. When T is a discriminated union, [K in keyof T] only iterates the common keys — the union is lost before per-key processing ever runs.
  2. ExtractRef<T> projects the branded shape back through ResultTypeFromSelect, which drags the VirtualPropsRef intersection ($synced, $origin, $key, $collectionId) into the user-facing result whenever a Ref to a sub-object is selected. This is a pre-existing leak that the nested-union test makes visible.

Fix

  • Make Ref<T, Nullable> distribute over T via a T extends unknown ? RefBranch<T, Nullable> : never wrapper. Ref<A | B> becomes Ref<A> | Ref<B>, so keyof never sees the union.
  • Reshape ExtractRef<T> to distinguish a true Ref (brand carrier whose own keys are exactly the brand symbols + virtual props + keyof U) from a spread-produced inline object (which inherits the brand symbol from the spread but also carries extra keys). True Refs return the underlying user type U directly; spreads fall through to the original Prettify<ResultTypeFromSelect<WithoutRefBrand<T>>> projection (preserving virtual props as the spread tests require).
  • Add DeepNullable<U> so the Nullable=true flag (left/right/full joins) keeps propagating | undefined into every leaf, matching what the prior ResultTypeFromSelect recursion produced for nullable joins. This preserves all existing join-test behavior.

No changes outside packages/db/src/query/builder/types.ts. ~36 LOC.

Why not approach (a) (PR #1512)

PR #1512 adds an IsUnion-gated shortcut in the middle of the ResultTypeFromSelect cascade. It makes the shallow test pass under vitest's typecheck runner, but tsc --noEmit still flags both new tests as failing because virtual props leak into the projected union, and the nested case still collapses. This PR fixes both cases under tsc.

Verification

$ pnpm tsc --noEmit -p packages/db/tsconfig.json
# 0 errors in src/ or tests/ (one pre-existing unrelated `fractional-indexing`
# module-resolution error in the linked db-ivm package remains, identical on main)

$ pnpm vitest run --typecheck     # packages/db
Tests       867 passed (867)
Type Errors no errors

Both new union tests pass under both tsc and vitest. No regressions in the existing select / select-spread / join type-tests.

IDE display

Hovering a discriminated-union Ref field now shows Ref<A> | Ref<B> | Ref<C> instead of the previous collapsed form. This is an improvement (it exposes the union the user wrote) but is a visible change for anyone who was reading the collapsed display.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed .select() type inference so discriminated-union selections no longer collapse into the intersection of shared keys.
    • Improved preservation of discriminated unions in both top-level and nested selections.
    • Enhanced nullable discriminated-union handling to propagate nullability correctly through projections.
  • Tests
    • Added/expanded type-inference checks to verify discriminated-union behavior, nested unions, and object spread projection outcomes.
  • Documentation
    • Updated the release note entry describing the patch.

kevin-dp and others added 2 commits June 18, 2026 15:35
…ck#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.
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Refactors Ref<T, Nullable> and ExtractRef<T> to preserve discriminated unions through .select(), and adds type-level tests plus a changeset entry documenting the patch.

Changes

Select discriminated union type preservation

Layer / File(s) Summary
Distributive Ref and ExtractRef logic
packages/db/src/query/builder/types.ts
ExtractRef now distinguishes true branded refs from spread-derived shapes and applies DeepNullable for nullable refs. Ref<T, Nullable> now delegates through RefBranch<T, Nullable> for distributive evaluation over union T.
Type-level regression tests and changeset
packages/db/tests/query/select.test-d.ts, .changeset/fix-ref-union-collapse-b.md
Adds eq-based type tests for preserved discriminated unions in top-level and nested select paths, plus spread projection behavior, and records the patch in a Changesets note.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

  • #1467: The DeepNullable and ExtractRef changes touch nullable ref extraction and selection typing, which aligns with the related issue’s focus on Ref/ExtractRef result typing.

Poem

🐇 A union once flattened now hops in full hue,
Through select() it travels, still branching anew.
RefBranch and DeepNullable dance in the night,
With tests guarding shapes so the types stay right.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately describes the main change: preserving discriminated unions through .select().
Description check ✅ Passed It covers the bug, root cause, fix, and verification, though the template's checklist and release-impact sections are left unfilled.
Linked Issues check ✅ Passed The code and tests address #1511 by preserving union members through .select(), including nested unions and spread regressions.
Out of Scope Changes check ✅ Passed Changes are limited to the type fix, type tests, and a changeset, all of which align with the stated issue.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jun 18, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@1597

@tanstack/browser-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/browser-db-sqlite-persistence@1597

@tanstack/capacitor-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/capacitor-db-sqlite-persistence@1597

@tanstack/cloudflare-durable-objects-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/cloudflare-durable-objects-db-sqlite-persistence@1597

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@1597

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@1597

@tanstack/db-sqlite-persistence-core

npm i https://pkg.pr.new/@tanstack/db-sqlite-persistence-core@1597

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@1597

@tanstack/electron-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/electron-db-sqlite-persistence@1597

@tanstack/expo-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/expo-db-sqlite-persistence@1597

@tanstack/node-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/node-db-sqlite-persistence@1597

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@1597

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@1597

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@1597

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@1597

@tanstack/react-native-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/react-native-db-sqlite-persistence@1597

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@1597

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@1597

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@1597

@tanstack/tauri-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/tauri-db-sqlite-persistence@1597

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@1597

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@1597

commit: 89d6dde

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
packages/db/src/query/builder/types.ts (1)

513-515: ⚡ Quick win

Prefer unknown over any in DeepNullable constraint.

Using Record<string, unknown> keeps this helper type-safe while preserving intent. Please re-run the existing type-level tests after the swap.

♻️ Proposed change
 type DeepNullable<T> =
-  T extends Record<string, any>
+  T extends Record<string, unknown>
     ? IsPlainObject<T> extends true
       ? { [K in keyof T]: DeepNullable<T[K]> }
       : T | undefined
     : T | undefined

As per coding guidelines, "**/*.{ts,tsx}: Avoid using any types; use unknown instead when the type is truly unknown`."

🤖 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/src/query/builder/types.ts` around lines 513 - 515, The
DeepNullable type definition uses Record<string, any> in its constraint which
violates the type-safety coding guidelines. Replace the any keyword with unknown
in the Record constraint within the DeepNullable type to maintain type-safety
while preserving the intent of the helper type, then run the existing type-level
tests to ensure the change does not break any type definitions.

Source: Coding guidelines

🤖 Prompt for all review comments with 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.

Nitpick comments:
In `@packages/db/src/query/builder/types.ts`:
- Around line 513-515: The DeepNullable type definition uses Record<string, any>
in its constraint which violates the type-safety coding guidelines. Replace the
any keyword with unknown in the Record constraint within the DeepNullable type
to maintain type-safety while preserving the intent of the helper type, then run
the existing type-level tests to ensure the change does not break any type
definitions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 775011a2-7dad-4cac-a956-a65a2f2e1430

📥 Commits

Reviewing files that changed from the base of the PR and between 00389a4 and b9a6f95.

📒 Files selected for processing (3)
  • .changeset/fix-ref-union-collapse-b.md
  • packages/db/src/query/builder/types.ts
  • packages/db/tests/query/select.test-d.ts

@KyleAMathews

Copy link
Copy Markdown
Collaborator

Thanks for the fix here — the distributive RefBranch change looks like it addresses the direct/nested union cases from #1511. I found one blocking regression in the new IsTrueRef fast path around spread projections.

IsTrueRef<T> currently checks only that T has no keys outside the brand keys, nullable brand, virtual row props, and keyof U:

type IsTrueRef<T> =
  T extends RefLeaf<infer U>
    ? [
        Exclude<
          keyof T,
          | typeof RefBrand
          | typeof NullableBrand
          | keyof VirtualRowProps
          | keyof U
        >,
      ] extends [never]
      ? true
      : false
    : false

That proves “no extra keys”, but it does not prove that T is exactly the canonical Ref<U> shape. A spread-derived object can still have only keys from keyof U while having changed or removed fields. In those cases this branch returns U from ExtractRef and discards the actual projection.

I reproduced two failing type cases against this PR.

1. Same-key compatible/narrower override

type User = {
  id: number
  code: string | number
  slug: string
  nickname?: string
}

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

const result = col.toArray[0]!
expectTypeOf(result.narrowed.code).toEqualTypeOf<string>()

u.slug projects to string, so result.narrowed.code should be string. On this PR, the type assertion fails because the projection is classified as the original ref shape rather than recursively projected.

2. Omitted optional property is reintroduced

type User = {
  id: number
  code: string | number
  slug: string
  nickname?: string
}

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

const result = col.toArray[0]!
type HasNickname = `nickname` extends keyof typeof result.trimmed ? true : false
expectTypeOf<HasNickname>().toEqualTypeOf<false>()

Since nickname was destructured out, result.trimmed should not have a nickname key. On this PR the assertion fails: nickname is still present because withoutNickname is treated as an untouched Ref<User>.

The existing spread regression test for { ...u.profile } covers an unchanged spread, but it does not cover modified same-key fields or omitted optional fields.

I also verified locally that tightening IsTrueRef to a bidirectional/full-shape equivalence check against the canonical Ref<U, IsNullableRef<T>> shape makes these new regression tests pass. The exact implementation may be worth tuning for readability/compiler performance, but the important part is that key-set inclusion is not sufficient here — this needs to prove the projected member ref types and key presence match the canonical ref shape before ExtractRef can safely return U.

Could you add regression tests for the two cases above and update IsTrueRef to use a full-shape/equivalence check rather than the current subset/no-extra-keys check?

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>

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In `@packages/db/tests/query/select.test-d.ts`:
- 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 56f04438-ac1f-4293-a106-c10bfc701be2

📥 Commits

Reviewing files that changed from the base of the PR and between b9a6f95 and 89d6dde.

📒 Files selected for processing (3)
  • .changeset/fix-ref-union-collapse-b.md
  • packages/db/src/query/builder/types.ts
  • packages/db/tests/query/select.test-d.ts
✅ Files skipped from review due to trivial changes (1)
  • .changeset/fix-ref-union-collapse-b.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/db/src/query/builder/types.ts


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

Comment on lines +223 to +229
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>()

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.

@kevin-dp

kevin-dp commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

@KyleAMathews I've pushed a fix that replaces the subset/no-extra-keys check in IsTrueRef with a strict structural-equivalence check against the canonical Ref<U, IsNullableRef<T>> shape:

type IsTrueRef<T> =
  T extends RefLeaf<infer U>
    ? RefShapeMatches<T, Ref<U, IsNullableRef<T>>> extends true
      ? true
      : false
    : false

// Strict structural equivalence (Equals idiom)
type RefShapeMatches<A, B> =
  (<G>() => G extends A ? 1 : 2) extends <G>() => G extends B ? 1 : 2
    ? true
    : false

A couple of notes on the choice of check:

  • I went with strict equality rather than bidirectional assignability because assignability is blind to optional-key presence — your 2nd case (const { nickname, ...withoutNickname } = u) stays mutually assignable to the canonical shape, so a mutual-assignability check still reintroduces nickname. The Equals form is sensitive to key presence and handles it correctly.
  • Genuine refs (direct refs, discriminated-union members) are exactly the canonical Ref shape, so they still take the fast path and unions are preserved. Anything spread-derived now falls through to ResultTypeFromSelect, which reconstructs the right type.

I also added the two regression tests you provided to select.test-d.ts. I verified they fail on the previous commit and pass after the fix, and that the full db package plus the dependent adapter packages (query-db-collection, vue-db, angular-db, electric-db-collection) all typecheck with 0 errors. Compile cost is negligible (~0.08% more instantiations, no TS2589).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

types: .select() collapses discriminated union types on object fields

3 participants