From 758db418c55e96883b191467b851e39a820baf79 Mon Sep 17 00:00:00 2001 From: kevin-dp Date: Thu, 18 Jun 2026 15:35:43 +0200 Subject: [PATCH 1/3] fix(db): preserve discriminated union types through .select() (#1511) Distribute Ref over its T parameter so a discriminated union field no longer has its keys collapsed by the mapped-type keyof. Reshape ExtractRef 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 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. --- .changeset/fix-ref-union-collapse-b.md | 5 ++ packages/db/src/query/builder/types.ts | 36 ++++++++++++-- packages/db/tests/query/select.test-d.ts | 62 +++++++++++++++++++++++- 3 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-ref-union-collapse-b.md diff --git a/.changeset/fix-ref-union-collapse-b.md b/.changeset/fix-ref-union-collapse-b.md new file mode 100644 index 0000000000..c405f4e4a6 --- /dev/null +++ b/.changeset/fix-ref-union-collapse-b.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db": patch +--- + +Fix `.select()` collapsing discriminated-union fields to the intersection of common keys (#1511). `Ref` now distributes over `T` so `keyof (A | B | C)` no longer reduces the union to its common keys, and `ExtractRef` 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. diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index ac6a95dac4..d69f0c57f0 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -478,8 +478,34 @@ type ResultTypeFromCaseWhen = T extends unknown ? ResultTypeFromSelectValue : never -// Extract Ref or subobject with a spread or a Ref -type ExtractRef = Prettify>> +// Extract Ref or subobject with a spread or a Ref. +type ExtractRef = T extends unknown + ? IsTrueRef extends true + ? T extends RefLeaf + ? IsNullableRef extends true + ? DeepNullable + : U + : never + : Prettify>> + : never + +// A "true" Ref is one whose own keys are exactly the brand+virtual props +// plus the keys of its underlying user type U (after distribution this means +// no extra keys from a spread were merged in). When extra keys are present +// (the case for spread-produced inline objects that pick up the RefBrand +// symbol from spreading a Ref), we fall through to the recursive projection. +type IsTrueRef = T extends RefLeaf + ? [Exclude] extends [never] + ? true + : false + : false + +// Propagate nullable-join semantics into the user-data shape. +type DeepNullable = T extends Record + ? IsPlainObject extends true + ? { [K in keyof T]: DeepNullable } + : T | undefined + : T | undefined // Helper type to extract the underlying type from various expression types type ExtractExpressionType = @@ -770,7 +796,11 @@ type VirtualPropsRef = { * select(({ user }) => ({ ...user })) // Returns User type, not Ref types * ``` */ -export type Ref = { +export type Ref = T extends unknown + ? RefBranch + : never + +type RefBranch = { [K in keyof T]: IsNonExactOptional extends true ? IsNonExactNullable extends true ? // Both optional and nullable diff --git a/packages/db/tests/query/select.test-d.ts b/packages/db/tests/query/select.test-d.ts index 225decdfb3..e4eb8a775a 100644 --- a/packages/db/tests/query/select.test-d.ts +++ b/packages/db/tests/query/select.test-d.ts @@ -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' @@ -109,6 +109,66 @@ describe(`select types`, () => { expectTypeOf(results).toMatchTypeOf>() }) + 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({ + 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() + }) + + 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({ + 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() + }) + test(`nested spread preserves object structure types`, () => { const users = createUsers() const col = createLiveQueryCollection((q) => { From b9a6f952501924d496505a00c0b999c7e055e131 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:37:17 +0000 Subject: [PATCH 2/3] ci: apply automated fixes --- .changeset/fix-ref-union-collapse-b.md | 2 +- packages/db/src/query/builder/types.ts | 26 ++++++++++++++++++-------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.changeset/fix-ref-union-collapse-b.md b/.changeset/fix-ref-union-collapse-b.md index c405f4e4a6..bf46853901 100644 --- a/.changeset/fix-ref-union-collapse-b.md +++ b/.changeset/fix-ref-union-collapse-b.md @@ -1,5 +1,5 @@ --- -"@tanstack/db": patch +'@tanstack/db': patch --- Fix `.select()` collapsing discriminated-union fields to the intersection of common keys (#1511). `Ref` now distributes over `T` so `keyof (A | B | C)` no longer reduces the union to its common keys, and `ExtractRef` 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. diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index d69f0c57f0..9bd54ab43a 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -494,18 +494,28 @@ type ExtractRef = T extends unknown // no extra keys from a spread were merged in). When extra keys are present // (the case for spread-produced inline objects that pick up the RefBrand // symbol from spreading a Ref), we fall through to the recursive projection. -type IsTrueRef = T extends RefLeaf - ? [Exclude] extends [never] - ? true +type IsTrueRef = + T extends RefLeaf + ? [ + Exclude< + keyof T, + | typeof RefBrand + | typeof NullableBrand + | keyof VirtualRowProps + | keyof U + >, + ] extends [never] + ? true + : false : false - : false // Propagate nullable-join semantics into the user-data shape. -type DeepNullable = T extends Record - ? IsPlainObject extends true - ? { [K in keyof T]: DeepNullable } +type DeepNullable = + T extends Record + ? IsPlainObject extends true + ? { [K in keyof T]: DeepNullable } + : T | undefined : T | undefined - : T | undefined // Helper type to extract the underlying type from various expression types type ExtractExpressionType = From 89d6dde077cbbfb35aef52958f7e652a8640a613 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Fri, 26 Jun 2026 09:49:40 +0200 Subject: [PATCH 3/3] 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` 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) --- .changeset/fix-ref-union-collapse-b.md | 2 +- packages/db/src/query/builder/types.ts | 43 +++++++++++------ packages/db/tests/query/select.test-d.ts | 60 ++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 15 deletions(-) diff --git a/.changeset/fix-ref-union-collapse-b.md b/.changeset/fix-ref-union-collapse-b.md index bf46853901..05e42450a2 100644 --- a/.changeset/fix-ref-union-collapse-b.md +++ b/.changeset/fix-ref-union-collapse-b.md @@ -2,4 +2,4 @@ '@tanstack/db': patch --- -Fix `.select()` collapsing discriminated-union fields to the intersection of common keys (#1511). `Ref` now distributes over `T` so `keyof (A | B | C)` no longer reduces the union to its common keys, and `ExtractRef` 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. +Fix `.select()` collapsing discriminated-union fields to the intersection of common keys (#1511). `Ref` now distributes over `T` so `keyof (A | B | C)` no longer reduces the union to its common keys, and `ExtractRef` 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` 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`. diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 9bd54ab43a..d8b730e76b 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -489,26 +489,41 @@ type ExtractRef = T extends unknown : Prettify>> : never -// A "true" Ref is one whose own keys are exactly the brand+virtual props -// plus the keys of its underlying user type U (after distribution this means -// no extra keys from a spread were merged in). When extra keys are present -// (the case for spread-produced inline objects that pick up the RefBrand -// symbol from spreading a Ref), we fall through to the recursive projection. +// A "true" Ref is one that is structurally equivalent to the canonical +// `Ref` 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 extends RefLeaf - ? [ - Exclude< - keyof T, - | typeof RefBrand - | typeof NullableBrand - | keyof VirtualRowProps - | keyof U - >, - ] extends [never] + ? RefShapeMatches>> 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 = + (() => G extends A ? 1 : 2) extends () => G extends B ? 1 : 2 + ? true + : false + // Propagate nullable-join semantics into the user-data shape. type DeepNullable = T extends Record diff --git a/packages/db/tests/query/select.test-d.ts b/packages/db/tests/query/select.test-d.ts index e4eb8a775a..c40b3607df 100644 --- a/packages/db/tests/query/select.test-d.ts +++ b/packages/db/tests/query/select.test-d.ts @@ -169,6 +169,66 @@ describe(`select types`, () => { expectTypeOf(r.payload.inner).toEqualTypeOf() }) + 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({ + 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() + }) + + 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({ + id: `spread-omit-users`, + getKey: (u) => u.id, + initialData: [], + }), + ) + + const col = createLiveQueryCollection((q) => + q.from({ u: spreadUsers }).select(({ u }) => { + const { nickname, ...withoutNickname } = u + 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().toEqualTypeOf() + }) + test(`nested spread preserves object structure types`, () => { const users = createUsers() const col = createLiveQueryCollection((q) => {