From 257cebb800568deeee389e7c7b1a978f07e4d090 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 18 Sep 2026 17:31:02 -0600 Subject: [PATCH 1/3] fix(db): preserve optional nullable query fields --- packages/db/src/query/builder/types.ts | 95 ++++++++++--------- .../query/query-api-type-algebra.test-d.ts | 87 +++++++++++++++++ 2 files changed, 139 insertions(+), 43 deletions(-) diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index aaac60aa7..8a12c7630 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -347,24 +347,27 @@ export type ResultTypeFromSelectValue = > extends true ? T | null | undefined : T | null - : TSelectValue extends Ref | undefined - ? - | ExtractRef> + : TSelectValue extends + | Ref + | null | undefined - : TSelectValue extends Ref | null - ? ExtractRef> | null - : TSelectValue extends Aggregate - ? T - : TSelectValue extends - | string - | number - | boolean - | null - | undefined - ? TSelectValue - : TSelectValue extends Record - ? ResultTypeFromSelect - : never + ? + | ExtractRef< + Exclude + > + | Extract + : TSelectValue extends Aggregate + ? T + : TSelectValue extends + | string + | number + | boolean + | null + | undefined + ? TSelectValue + : TSelectValue extends Record + ? ResultTypeFromSelect + : never > /** @@ -452,35 +455,37 @@ export type ResultTypeFromSelect = > extends true ? T | null | undefined : T | null - : // Ref | undefined (optional object-type schema field) + : // Nullable and/or optional object-type schema field TSelectObject[K] extends | Ref + | null | undefined ? | ExtractRef< - Exclude + Exclude< + TSelectObject[K], + null | undefined + > > - | undefined - : // Ref | null (nullable object-type schema field) - TSelectObject[K] extends Ref | null - ? ExtractRef< - Exclude - > | null - : TSelectObject[K] extends Aggregate - ? T - : TSelectObject[K] extends - | string - | number - | boolean - | null - | undefined - ? TSelectObject[K] - : TSelectObject[K] extends Record< - string, - any - > - ? ResultTypeFromSelect - : never + | Extract< + TSelectObject[K], + null | undefined + > + : TSelectObject[K] extends Aggregate + ? T + : TSelectObject[K] extends + | string + | number + | boolean + | null + | undefined + ? TSelectObject[K] + : TSelectObject[K] extends Record< + string, + any + > + ? ResultTypeFromSelect + : never }> > @@ -702,12 +707,16 @@ type RefForContextSchemaValue< ? RefForContextValue, true> : IsNonExactOptional extends true ? IsNonExactNullable extends true - ? RefForContextValue, true> + ? RefForOptionalNullableContextValue> : RefForContextValue, true> : IsNonExactNullable extends true ? RefForContextValue, true> : RefForContextValue +type RefForOptionalNullableContextValue = T extends null + ? null + : RefForContextValue + type RefsForBranchResult = T extends unknown ? { [K in keyof T]: ForceNullable extends true @@ -873,8 +882,8 @@ type RefBranch = { ? IsNonExactNullable extends true ? // Both optional and nullable IsPlainObject> extends true - ? Ref, Nullable> | undefined - : RefLeaf, Nullable> | undefined + ? Ref, Nullable> | null | undefined + : RefLeaf, Nullable> | undefined : // Optional only IsPlainObject> extends true ? Ref, Nullable> | undefined diff --git a/packages/db/tests/query/query-api-type-algebra.test-d.ts b/packages/db/tests/query/query-api-type-algebra.test-d.ts index fc4a02843..42136f3ce 100644 --- a/packages/db/tests/query/query-api-type-algebra.test-d.ts +++ b/packages/db/tests/query/query-api-type-algebra.test-d.ts @@ -19,9 +19,11 @@ import type { Context, QueryBuilder, QueryResult, + Ref, RefsForContext, WithResult, } from '../../src/query/index.js' +import type { RefLeaf } from '../../src/query/builder/types.js' import type { WithVirtualProps } from '../../src/virtual-props.js' type Row = { id: string; departmentId: string } @@ -391,6 +393,91 @@ describe(`query API type algebra`, () => { void projectNullishLeaves }) + test(`optional nullable fields preserve both nullish branches`, () => { + type OptionalNullableRow = { + id: string + required: number + optional?: number + nullable: number | null + nullish?: number | null + exactNull: null + exactUndefined: undefined + nullishObject?: { label: string } | null + } + + function projectOptionalNullableFields( + source: Collection, + ) { + const query = new Query() + .from({ row: source }) + .orderBy(({ row }) => { + expectTypeOf(row.required).toEqualTypeOf>() + expectTypeOf(row.optional).toEqualTypeOf< + RefLeaf | undefined + >() + expectTypeOf(row.nullable).toEqualTypeOf | null>() + expectTypeOf(row.nullish).toEqualTypeOf< + RefLeaf | undefined + >() + expectTypeOf(row.exactNull).toEqualTypeOf>() + expectTypeOf(row.exactUndefined).toEqualTypeOf>() + expectTypeOf(row.nullishObject).toEqualTypeOf< + Ref<{ label: string }> | null | undefined + >() + return row.nullish + }) + .select(({ row }) => ({ + required: row.required, + optional: row.optional, + nullable: row.nullable, + nullish: row.nullish, + exactNull: row.exactNull, + exactUndefined: row.exactUndefined, + nullishObject: row.nullishObject, + })) + + type Result = QueryResult + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + number | null | undefined + >() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + { label: string } | null | undefined + >() + + const branch = () => + new Query().from({ row: source }).select(({ row }) => ({ + nullish: row.nullish, + nullishObject: row.nullishObject, + })) + const union = new Query() + .unionAll(branch(), branch()) + .orderBy(({ nullish }) => { + expectTypeOf(nullish).toEqualTypeOf | null>() + return nullish + }) + .select(({ nullish, nullishObject }) => ({ + nullish, + nullishObject, + })) + type UnionResult = QueryResult + expectTypeOf().toEqualTypeOf< + number | null | undefined + >() + expectTypeOf().toEqualTypeOf< + { label: string } | null | undefined + >() + + return { query, union } + } + + void projectOptionalNullableFields + }) + test(`branch unions preserve intrinsic nullish fields through nullable joins`, () => { type BranchRow = { id: string From d908b039b7ff251201701e4f0a1e2d0d62778cef Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 18 Sep 2026 17:32:37 -0600 Subject: [PATCH 2/3] chore: add optional nullable query fields changeset --- .changeset/fix-optional-nullable-query-fields.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-optional-nullable-query-fields.md diff --git a/.changeset/fix-optional-nullable-query-fields.md b/.changeset/fix-optional-nullable-query-fields.md new file mode 100644 index 000000000..d06c27787 --- /dev/null +++ b/.changeset/fix-optional-nullable-query-fields.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Preserve `null` alongside `undefined` when optional nullable fields flow through query references and selected results. From 4ddb656b21e38b2ceadc8c294d1722f33e8096a1 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 19 Sep 2026 21:05:13 +0100 Subject: [PATCH 3/3] fix(db): preserve nullable branch union fields --- .../fix-optional-nullable-query-fields.md | 2 +- packages/db/src/query/builder/types.ts | 2 +- .../query/query-api-type-algebra.test-d.ts | 53 ++++++++++++++++++- .../query/query-api-type-algebra.test.ts | 36 +++++++++++++ 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/.changeset/fix-optional-nullable-query-fields.md b/.changeset/fix-optional-nullable-query-fields.md index d06c27787..9b9be21cd 100644 --- a/.changeset/fix-optional-nullable-query-fields.md +++ b/.changeset/fix-optional-nullable-query-fields.md @@ -2,4 +2,4 @@ '@tanstack/db': patch --- -Preserve `null` alongside `undefined` when optional nullable fields flow through query references and selected results. +Preserve `null` alongside `undefined` when nullable fields flow through query references, selected results, and branch unions. diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 8a12c7630..ed860c7f1 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -710,7 +710,7 @@ type RefForContextSchemaValue< ? RefForOptionalNullableContextValue> : RefForContextValue, true> : IsNonExactNullable extends true - ? RefForContextValue, true> + ? RefForContextValue, true> | Extract : RefForContextValue type RefForOptionalNullableContextValue = T extends null diff --git a/packages/db/tests/query/query-api-type-algebra.test-d.ts b/packages/db/tests/query/query-api-type-algebra.test-d.ts index 42136f3ce..7ef833f3f 100644 --- a/packages/db/tests/query/query-api-type-algebra.test-d.ts +++ b/packages/db/tests/query/query-api-type-algebra.test-d.ts @@ -483,6 +483,7 @@ describe(`query API type algebra`, () => { id: string exactNull: null exactUndefined: undefined + nullableNumber: number | null nullableText: string | null nullableObject: { label: string } | null } @@ -497,16 +498,41 @@ describe(`query API type algebra`, () => { id: value.id, exactNull: value.exactNull, exactUndefined: value.exactUndefined, + nullableNumber: value.nullableNumber, nullableText: value.nullableText, nullableObject: value.nullableObject, })) const union = new Query().unionAll(branch(a), branch(b)) + const _plain = union.select( + ({ nullableNumber, nullableText, nullableObject }) => { + expectTypeOf(nullableNumber).toEqualTypeOf | null>() + expectTypeOf(nullableText).toEqualTypeOf | null>() + expectTypeOf(nullableObject).toEqualTypeOf | null>() + return { nullableNumber, nullableText, nullableObject } + }, + ) const rightJoined = union .rightJoin({ row: rows }, ({ id, row }) => eq(id, row.id)) .select( - ({ exactNull, exactUndefined, nullableText, nullableObject }) => ({ + ({ + exactNull, + exactUndefined, + nullableNumber, + nullableText, + nullableObject, + }) => ({ exactNull, exactUndefined, + nullableNumber, nullableText, nullableObject, }), @@ -514,18 +540,38 @@ describe(`query API type algebra`, () => { const fullJoined = union .fullJoin({ row: rows }, ({ id, row }) => eq(id, row.id)) .select( - ({ exactNull, exactUndefined, nullableText, nullableObject }) => ({ + ({ exactNull, exactUndefined, + nullableNumber, + nullableText, + nullableObject, + }) => ({ + exactNull, + exactUndefined, + nullableNumber, nullableText, nullableObject, }), ) + type PlainResult = QueryResult type RightResult = QueryResult type FullResult = QueryResult + expectTypeOf().toEqualTypeOf< + number | null | undefined + >() + expectTypeOf().toEqualTypeOf< + string | null | undefined + >() + expectTypeOf().toEqualTypeOf< + { label: string } | null | undefined + >() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + number | null | undefined + >() expectTypeOf().toEqualTypeOf< string | null | undefined >() @@ -534,6 +580,9 @@ describe(`query API type algebra`, () => { >() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + number | null | undefined + >() expectTypeOf().toEqualTypeOf< string | null | undefined >() diff --git a/packages/db/tests/query/query-api-type-algebra.test.ts b/packages/db/tests/query/query-api-type-algebra.test.ts index 6f276cf39..eb3025c8d 100644 --- a/packages/db/tests/query/query-api-type-algebra.test.ts +++ b/packages/db/tests/query/query-api-type-algebra.test.ts @@ -89,3 +89,39 @@ test(`branch unions publish unmatched whole-object projections as undefined`, as expect(query.toArray[0]!.other).toMatchObject({ id: `row-1` }) expect(query.toArray[1]!.other).toBeUndefined() }) + +test(`plain branch unions publish nullable-only values as null`, async () => { + type NullableRow = { id: string; value: number | null } + const rowsA = createCollection( + mockSyncCollectionOptions({ + id: `query-api-type-algebra-nullable-rows-a`, + getKey: (row) => row.id, + initialData: [{ id: `null-row`, value: null }], + }), + ) + const rowsB = createCollection( + mockSyncCollectionOptions({ + id: `query-api-type-algebra-nullable-rows-b`, + getKey: (row) => row.id, + initialData: [{ id: `number-row`, value: 1 }], + }), + ) + const query = createLiveQueryCollection((q) => { + const branchA = q.from({ rowsA }).select(({ rowsA: row }) => ({ + id: row.id, + value: row.value, + })) + const branchB = q.from({ rowsB }).select(({ rowsB: row }) => ({ + id: row.id, + value: row.value, + })) + return q.unionAll(branchA, branchB).select(({ id, value }) => ({ + id, + value, + })) + }) + + await query.preload() + + expect(query.toArray.find((row) => row.id === `null-row`)?.value).toBeNull() +})