diff --git a/.changeset/fix-ref-union-collapse-b.md b/.changeset/fix-ref-union-collapse-b.md new file mode 100644 index 000000000..bf4685390 --- /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..9bd54ab43 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -478,8 +478,44 @@ 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< + keyof T, + | typeof RefBrand + | typeof NullableBrand + | keyof VirtualRowProps + | keyof U + >, + ] 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 +806,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) => {