From 758db418c55e96883b191467b851e39a820baf79 Mon Sep 17 00:00:00 2001 From: kevin-dp Date: Thu, 18 Jun 2026 15:35:43 +0200 Subject: [PATCH 1/2] 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 000000000..c405f4e4a --- /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 ac6a95dac..d69f0c57f 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 225decdfb..e4eb8a775 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/2] 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 c405f4e4a..bf4685390 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 d69f0c57f..9bd54ab43 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 =