From 3ef2b1e0b79c0ef2bced2f9ffff1724589237c5a Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 18 Feb 2026 10:47:08 +0100 Subject: [PATCH 1/5] test(db): add type tests for left join select ref direct property access Declarative select callback types currently use `Ref | undefined` for left-joined tables, forcing optional chaining. Since the callback receives proxy refs that are always truthy, this is misleading and encourages runtime conditional patterns that silently fail. These tests assert the desired behavior: direct property access without optional chaining, with nullability reflected only in the result type. Co-Authored-By: Claude Opus 4.6 --- packages/db/tests/query/join.test-d.ts | 65 ++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/db/tests/query/join.test-d.ts b/packages/db/tests/query/join.test-d.ts index 9fc1a6bde..7edc9a562 100644 --- a/packages/db/tests/query/join.test-d.ts +++ b/packages/db/tests/query/join.test-d.ts @@ -701,6 +701,71 @@ describe(`Join Alias Methods - Type Safety`, () => { }) }) +describe(`Declarative select refs should not use union with undefined for nullable joins`, () => { + test(`left-joined ref in declarative select should allow direct property access without optional chaining`, () => { + const usersCollection = createUsersCollection() + const departmentsCollection = createDepartmentsCollection() + + const query = createLiveQueryCollection({ + query: (q) => + q + .from({ user: usersCollection }) + .leftJoin({ dept: departmentsCollection }, ({ user, dept }) => + eq(user.department_id, dept.id), + ) + .select(({ user, dept }) => ({ + userName: user.name, + // dept is a proxy ref that is always present at build time, + // so direct property access should work without optional chaining + deptName: dept.name, + deptBudget: dept.budget, + })), + }) + + const results = query.toArray + + // Result fields from left-joined tables should still produce T | undefined + // because the actual data may have no matching row + expectTypeOf(results).toEqualTypeOf< + Array<{ + userName: string + deptName: string | undefined + deptBudget: number | undefined + }> + >() + }) + + test(`inner-joined ref in declarative select should allow direct property access with non-optional result`, () => { + const usersCollection = createUsersCollection() + const departmentsCollection = createDepartmentsCollection() + + const query = createLiveQueryCollection({ + query: (q) => + q + .from({ user: usersCollection }) + .innerJoin({ dept: departmentsCollection }, ({ user, dept }) => + eq(user.department_id, dept.id), + ) + .select(({ user, dept }) => ({ + userName: user.name, + deptName: dept.name, + deptBudget: dept.budget, + })), + }) + + const results = query.toArray + + // Inner join fields should never be undefined + expectTypeOf(results).toEqualTypeOf< + Array<{ + userName: string + deptName: string + deptBudget: number + }> + >() + }) +}) + describe(`Join with ArkType Schemas`, () => { test(`join with optional foreign key using ArkType schema should work`, () => { // Define ArkType schemas with optional foreign key From 409f61b1fe3e03abeaebad9c56da51fd87b101cf Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 18 Feb 2026 10:51:40 +0100 Subject: [PATCH 2/5] test(db): add right join and full join type tests for select ref direct access Co-Authored-By: Claude Opus 4.6 --- packages/db/tests/query/join.test-d.ts | 56 ++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/db/tests/query/join.test-d.ts b/packages/db/tests/query/join.test-d.ts index 7edc9a562..01ca6151c 100644 --- a/packages/db/tests/query/join.test-d.ts +++ b/packages/db/tests/query/join.test-d.ts @@ -735,6 +735,62 @@ describe(`Declarative select refs should not use union with undefined for nullab >() }) + test(`right-joined ref in declarative select should allow direct property access on nullable left table`, () => { + const usersCollection = createUsersCollection() + const departmentsCollection = createDepartmentsCollection() + + const query = createLiveQueryCollection({ + query: (q) => + q + .from({ user: usersCollection }) + .rightJoin({ dept: departmentsCollection }, ({ user, dept }) => + eq(user.department_id, dept.id), + ) + .select(({ user, dept }) => ({ + // user is the nullable side in a right join + userName: user.name, + deptName: dept.name, + })), + }) + + const results = query.toArray + + expectTypeOf(results).toEqualTypeOf< + Array<{ + userName: string | undefined + deptName: string + }> + >() + }) + + test(`full-joined refs in declarative select should allow direct property access on both nullable tables`, () => { + const usersCollection = createUsersCollection() + const departmentsCollection = createDepartmentsCollection() + + const query = createLiveQueryCollection({ + query: (q) => + q + .from({ user: usersCollection }) + .fullJoin({ dept: departmentsCollection }, ({ user, dept }) => + eq(user.department_id, dept.id), + ) + .select(({ user, dept }) => ({ + userName: user.name, + deptName: dept.name, + })), + }) + + const results = query.toArray + + // Both sides are nullable in a full join + expectTypeOf(results).toEqualTypeOf< + Array<{ + userName: string | undefined + deptName: string | undefined + }> + >() + }) + test(`inner-joined ref in declarative select should allow direct property access with non-optional result`, () => { const usersCollection = createUsersCollection() const departmentsCollection = createDepartmentsCollection() From b5333a51a4c3addad16521153eb1da73670518a5 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 18 Feb 2026 14:04:19 +0100 Subject: [PATCH 3/5] fix(db): use Ref brand instead of Ref | undefined for nullable join refs The declarative select() callback receives proxy objects that record property accesses. These proxies are always truthy, but nullable join sides were typed as Ref | undefined, misleading users into using ?. and ?? operators that have no effect at runtime. This changes nullable join refs to Ref, which allows direct property access while correctly producing T | undefined in the result type. Co-Authored-By: Claude Opus 4.6 --- .../src/suites/joins.suite.ts | 74 +++++------ packages/db/src/query/builder/types.ts | 116 ++++++++++-------- .../query/builder/callback-types.test-d.ts | 108 ++++++++-------- .../tests/query/live-query-collection.test.ts | 7 +- 4 files changed, 161 insertions(+), 144 deletions(-) diff --git a/packages/db-collection-e2e/src/suites/joins.suite.ts b/packages/db-collection-e2e/src/suites/joins.suite.ts index 5f25bb20b..2a953d38c 100644 --- a/packages/db-collection-e2e/src/suites/joins.suite.ts +++ b/packages/db-collection-e2e/src/suites/joins.suite.ts @@ -24,9 +24,9 @@ export function createJoinsTestSuite(getConfig: () => Promise) { eq(user.id, post.userId), ) .select(({ user, post }) => ({ - id: post!.id, + id: post.id, userName: user.name, - postTitle: post!.title, + postTitle: post.title, })), ) @@ -53,12 +53,12 @@ export function createJoinsTestSuite(getConfig: () => Promise) { .join({ post: postsCollection }, ({ user, post }) => eq(user.id, post.userId), ) - .where(({ post }) => gt(post!.viewCount, 10)) + .where(({ post }) => gt(post.viewCount, 10)) .select(({ user, post }) => ({ - id: post!.id, + id: post.id, userName: user.name, - postTitle: post!.title, - viewCount: post!.viewCount, + postTitle: post.title, + viewCount: post.viewCount, })), ) @@ -88,9 +88,9 @@ export function createJoinsTestSuite(getConfig: () => Promise) { eq(user.id, post.userId), ) .select(({ user, post }) => ({ - id: post!.id, + id: post.id, userName: user.name, - postTitle: post!.title, + postTitle: post.title, })), ) @@ -117,12 +117,12 @@ export function createJoinsTestSuite(getConfig: () => Promise) { .join({ post: postsCollection }, ({ user, post }) => eq(user.id, post.userId), ) - .orderBy(({ post }) => post!.viewCount, `desc`) + .orderBy(({ post }) => post.viewCount, `desc`) .select(({ user, post }) => ({ - id: post!.id, + id: post.id, userName: user.name, - postTitle: post!.title, - viewCount: post!.viewCount, + postTitle: post.title, + viewCount: post.viewCount, })), ) @@ -149,7 +149,7 @@ export function createJoinsTestSuite(getConfig: () => Promise) { for (let i = 1; i < results.length; i++) { const prevCount = results[i - 1]!.viewCount const currCount = results[i]!.viewCount - expect(prevCount).toBeGreaterThanOrEqual(currCount) + expect(prevCount!).toBeGreaterThanOrEqual(currCount!) } await query.cleanup() @@ -166,13 +166,13 @@ export function createJoinsTestSuite(getConfig: () => Promise) { .join({ post: postsCollection }, ({ user, post }) => eq(user.id, post.userId), ) - .orderBy(({ post }) => post!.id, `asc`) + .orderBy(({ post }) => post.id, `asc`) .limit(10) .offset(5) .select(({ user, post }) => ({ - id: post!.id, + id: post.id, userName: user.name, - postTitle: post!.title, + postTitle: post.title, })), ) @@ -194,13 +194,13 @@ export function createJoinsTestSuite(getConfig: () => Promise) { .from({ user: users }) .join({ post: posts }, ({ user, post }) => eq(user.id, post.userId)) .join({ comment: comments }, ({ post, comment }) => - eq(post!.id, comment.postId), + eq(post.id, comment.postId), ) .select(({ user, post, comment }) => ({ - id: comment!.id, + id: comment.id, userName: user.name, - postTitle: post!.title, - commentText: comment!.text, + postTitle: post.title, + commentText: comment.text, })), ) @@ -225,16 +225,16 @@ export function createJoinsTestSuite(getConfig: () => Promise) { .from({ user: users }) .where(({ user }) => eq(user.isActive, true)) .join({ post: posts }, ({ user, post }) => eq(user.id, post.userId)) - .where(({ post }) => isNull(post!.deletedAt)) + .where(({ post }) => isNull(post.deletedAt)) .join({ comment: comments }, ({ post, comment }) => - eq(post!.id, comment.postId), + eq(post.id, comment.postId), ) - .where(({ comment }) => isNull(comment!.deletedAt)) + .where(({ comment }) => isNull(comment.deletedAt)) .select(({ user, post, comment }) => ({ - id: comment!.id, + id: comment.id, userName: user.name, - postTitle: post!.title, - commentText: comment!.text, + postTitle: post.title, + commentText: comment.text, })), ) @@ -264,13 +264,13 @@ export function createJoinsTestSuite(getConfig: () => Promise) { eq(user.id, post.userId), ) .join({ comment: commentsOnDemand }, ({ post, comment }) => - eq(post!.id, comment.postId), + eq(post.id, comment.postId), ) .select(({ user, post, comment }) => ({ - id: comment!.id, + id: comment.id, userName: user.name, - postTitle: post!.title, - commentText: comment!.text, + postTitle: post.title, + commentText: comment.text, })), ) @@ -296,12 +296,12 @@ export function createJoinsTestSuite(getConfig: () => Promise) { q .from({ user: users }) .join({ post: posts }, ({ user, post }) => eq(user.id, post.userId)) - .where(({ post }) => gt(post!.viewCount, 50)) + .where(({ post }) => gt(post.viewCount, 50)) .select(({ user, post }) => ({ - id: post!.id, + id: post.id, userName: user.name, - postTitle: post!.title, - viewCount: post!.viewCount, + postTitle: post.title, + viewCount: post.viewCount, })), ) @@ -326,10 +326,10 @@ export function createJoinsTestSuite(getConfig: () => Promise) { .where(({ user }) => gt(user.age, 30)) .join({ post: posts }, ({ user, post }) => eq(user.id, post.userId)) .select(({ user, post }) => ({ - id: post!.id, + id: post.id, userName: user.name, userAge: user.age, - postTitle: post!.title, + postTitle: post.title, })), ) @@ -359,7 +359,7 @@ export function createJoinsTestSuite(getConfig: () => Promise) { .select(({ user, post }) => ({ id: user.id, userName: user.name, - postTitle: post!.title, // May be null for users without posts + postTitle: post.title, // May be null for users without posts })), ) diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 11360dd82..b4639fdb8 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -227,18 +227,30 @@ export type ResultTypeFromSelect = WithoutRefBrand< Prettify<{ [K in keyof TSelectObject]: NeedsExtraction extends true ? ExtractExpressionType - : TSelectObject[K] extends Ref + : // Ref (full object ref or spread with RefBrand) - recursively process properties + TSelectObject[K] extends Ref ? ExtractRef - : TSelectObject[K] extends RefLeaf - ? T - : TSelectObject[K] extends RefLeaf | undefined + : // RefLeaf (simple property ref like user.name) + TSelectObject[K] extends RefLeaf + ? IsNullableRef extends true ? T | undefined - : TSelectObject[K] extends RefLeaf | null - ? T | null - : TSelectObject[K] extends Ref | undefined - ? ExtractRef | undefined - : TSelectObject[K] extends Ref | null - ? ExtractRef | null + : T + : // RefLeaf | undefined (schema-optional field) + TSelectObject[K] extends RefLeaf | undefined + ? T | undefined + : // RefLeaf | null (schema-nullable field) + TSelectObject[K] extends RefLeaf | null + ? IsNullableRef< + Exclude + > extends true + ? T | null | undefined + : T | null + : // Ref | undefined (optional object-type schema field) + TSelectObject[K] extends Ref | undefined + ? ExtractRef> | undefined + : // Ref | null (nullable object-type schema field) + TSelectObject[K] extends Ref | null + ? ExtractRef> | null : TSelectObject[K] extends Aggregate ? T : TSelectObject[K] extends @@ -366,24 +378,17 @@ export type FunctionalHavingRow = TContext[`schema`] & (TContext[`result`] extends object ? { $selected: TContext[`result`] } : {}) /** - * RefProxyForContext - Creates ref proxies for all tables/collections in a query context + * RefsForContext - Creates ref proxies for all tables/collections in a query context * * This is the main entry point for creating ref objects in query builder callbacks. - * It handles optionality by placing undefined/null OUTSIDE the RefProxy to enable - * JavaScript's optional chaining operator (?.): + * For nullable join sides (left/right/full joins), it produces `Ref` instead + * of `Ref | undefined`. This accurately reflects that the proxy object is always + * present at build time (it's a truthy proxy that records property access paths), + * while the `Nullable` flag ensures the result type correctly includes `| undefined`. * * Examples: - * - Required field: `RefProxy` → user.name works - * - Optional field: `RefProxy | undefined` → user?.name works - * - Nullable field: `RefProxy | null` → user?.name works - * - Both optional and nullable: `RefProxy | undefined` → user?.name works - * - * The key insight is that `RefProxy` would NOT allow `user?.name` - * because the undefined is "inside" the proxy, but `RefProxy | undefined` - * does allow it because the undefined is "outside" the proxy. - * - * The logic prioritizes optional chaining by always placing `undefined` outside when - * a type is both optional and nullable (e.g., `string | null | undefined`). + * - Required field: `Ref` → user.name works, result is T + * - Nullable join side: `Ref` → user.name works, result is T | undefined * * After `select()` is called, this type also includes `$selected` which provides access * to the SELECT result fields via `$selected.fieldName` syntax. @@ -394,17 +399,17 @@ export type RefsForContext = { > extends true ? IsNonExactNullable extends true ? // T is both non-exact optional and non-exact nullable (e.g., string | null | undefined) - // Extract the non-undefined and non-null part and place undefined outside - Ref> | undefined + // Extract the non-undefined and non-null part, mark as nullable ref + Ref, true> : // T is optional (T | undefined) but not exactly undefined, and not nullable - // Extract the non-undefined part and place undefined outside - Ref> | undefined + // Extract the non-undefined part, mark as nullable ref + Ref, true> : IsNonExactNullable extends true ? // T is nullable (T | null) but not exactly null, and not optional - // Extract the non-null part and place null outside - Ref> | null + // Extract the non-null part, mark as nullable ref + Ref, true> : // T is exactly undefined, exactly null, or neither optional nor nullable - // Wrap in RefProxy as-is (includes exact undefined, exact null, and normal types) + // Wrap in Ref as-is (includes exact undefined, exact null, and normal types) Ref } & (TContext[`result`] extends object ? { $selected: Ref } @@ -479,41 +484,44 @@ type NonNull = T extends null ? never : T * It provides a recursive interface that allows nested property access while * preserving optionality and nullability correctly. * - * When spread in select clauses, it correctly produces the underlying data type - * without Ref wrappers, enabling clean spread operations. + * The `Nullable` parameter indicates whether this ref comes from a nullable + * join side (left/right/full). When `true`, the `Nullable` flag propagates + * through all nested property accesses, ensuring the result type includes + * `| undefined` for all fields accessed through this ref. * * Example usage: * ```typescript - * // Clean interface - no internal properties visible - * const users: Ref<{ id: number; profile?: { bio: string } }> = { ... } - * users.id // Ref - clean display - * users.profile?.bio // Ref - nested optional access works + * // Non-nullable ref (inner join or from table): + * select(({ user }) => ({ name: user.name })) // result: string + * + * // Nullable ref (left join right side): + * select(({ dept }) => ({ name: dept.name })) // result: string | undefined * * // Spread operations work cleanly: * select(({ user }) => ({ ...user })) // Returns User type, not Ref types * ``` */ -export type Ref = { +export type Ref = { [K in keyof T]: IsNonExactOptional extends true ? IsNonExactNullable extends true ? // Both optional and nullable IsPlainObject> extends true - ? Ref> | undefined - : RefLeaf> | undefined + ? Ref, Nullable> | undefined + : RefLeaf, Nullable> | undefined : // Optional only IsPlainObject> extends true - ? Ref> | undefined - : RefLeaf> | undefined + ? Ref, Nullable> | undefined + : RefLeaf, Nullable> | undefined : IsNonExactNullable extends true ? // Nullable only IsPlainObject> extends true - ? Ref> | null - : RefLeaf> | null + ? Ref, Nullable> | null + : RefLeaf, Nullable> | null : // Required IsPlainObject extends true - ? Ref - : RefLeaf -} & RefLeaf + ? Ref + : RefLeaf +} & RefLeaf /** * Ref - The user-facing ref type with clean IDE display @@ -527,11 +535,19 @@ export type Ref = { * - No internal properties like __refProxy, __path, __type are visible */ declare const RefBrand: unique symbol -export type RefLeaf = { readonly [RefBrand]?: T } +declare const NullableBrand: unique symbol +export type RefLeaf = { + readonly [RefBrand]?: T +} & ([Nullable] extends [true] ? { readonly [NullableBrand]?: true } : {}) + +// Detect NullableBrand by checking for the key's presence +type IsNullableRef = typeof NullableBrand extends keyof T ? true : false -// Helper type to remove RefBrand from objects +// Helper type to remove RefBrand and NullableBrand from objects type WithoutRefBrand = - T extends Record ? Omit : T + T extends Record + ? Omit + : T /** * PreserveSingleResultFlag - Conditionally includes the singleResult flag diff --git a/packages/db/tests/query/builder/callback-types.test-d.ts b/packages/db/tests/query/builder/callback-types.test-d.ts index 0e901b7fb..edd606a54 100644 --- a/packages/db/tests/query/builder/callback-types.test-d.ts +++ b/packages/db/tests/query/builder/callback-types.test-d.ts @@ -122,17 +122,17 @@ describe(`Query Builder Callback Types`, () => { expectTypeOf( user.department_id, ).toEqualTypeOf | null>() - expectTypeOf(dept?.id).toEqualTypeOf | undefined>() - expectTypeOf(dept?.name).toEqualTypeOf | undefined>() - expectTypeOf(dept?.budget).toEqualTypeOf< - RefLeaf | undefined + expectTypeOf(dept.id).toEqualTypeOf>() + expectTypeOf(dept.name).toEqualTypeOf>() + expectTypeOf(dept.budget).toEqualTypeOf< + RefLeaf >() return { user_name: user.name, - dept_name: dept?.name, + dept_name: dept.name, user_email: user.email, - dept_budget: dept?.budget, + dept_budget: dept.budget, } }) }) @@ -263,17 +263,17 @@ describe(`Query Builder Callback Types`, () => { ) .where(({ user, dept }) => { expectTypeOf(user.active).toEqualTypeOf>() - expectTypeOf(dept?.active).toEqualTypeOf< - RefLeaf | undefined + expectTypeOf(dept.active).toEqualTypeOf< + RefLeaf >() - expectTypeOf(dept?.budget).toEqualTypeOf< - RefLeaf | undefined + expectTypeOf(dept.budget).toEqualTypeOf< + RefLeaf >() return and( eq(user.active, true), - eq(dept?.active, true), - gt(dept?.budget, 100000), + eq(dept.active, true), + gt(dept.budget, 100000), ) }) }) @@ -315,13 +315,13 @@ describe(`Query Builder Callback Types`, () => { ) .join({ project: projectsCollection }, ({ user, dept, project }) => { expectTypeOf(user.id).toEqualTypeOf>() - expectTypeOf(dept?.id).toEqualTypeOf | undefined>() + expectTypeOf(dept.id).toEqualTypeOf>() expectTypeOf(project.user_id).toEqualTypeOf>() expectTypeOf(project.department_id).toEqualTypeOf>() return and( eq(project.user_id, user.id), - eq(project.department_id, dept?.id), + eq(project.department_id, dept.id), ) }) }) @@ -360,10 +360,10 @@ describe(`Query Builder Callback Types`, () => { ) .orderBy(({ user, dept }) => { expectTypeOf(user.id).toEqualTypeOf>() - expectTypeOf(dept?.id).toEqualTypeOf | undefined>() - expectTypeOf(dept?.name).toEqualTypeOf | undefined>() + expectTypeOf(dept.id).toEqualTypeOf>() + expectTypeOf(dept.name).toEqualTypeOf>() - return dept?.name + return dept.name }) }) }) @@ -400,12 +400,12 @@ describe(`Query Builder Callback Types`, () => { ) .groupBy(({ user, dept }) => { expectTypeOf(user.id).toEqualTypeOf>() - expectTypeOf(dept?.id).toEqualTypeOf | undefined>() - expectTypeOf(dept?.location).toEqualTypeOf< - RefLeaf | undefined + expectTypeOf(dept.id).toEqualTypeOf>() + expectTypeOf(dept.location).toEqualTypeOf< + RefLeaf >() - return dept?.location + return dept.location }) }) }) @@ -481,12 +481,12 @@ describe(`Query Builder Callback Types`, () => { .join({ dept: departmentsCollection }, ({ user, dept }) => eq(user.department_id, dept.id), ) - .groupBy(({ dept }) => dept?.location) + .groupBy(({ dept }) => dept.location) .having(({ user, dept }) => { expectTypeOf(user.id).toEqualTypeOf>() - expectTypeOf(dept?.id).toEqualTypeOf | undefined>() - expectTypeOf(dept?.location).toEqualTypeOf< - RefLeaf | undefined + expectTypeOf(dept.id).toEqualTypeOf>() + expectTypeOf(dept.location).toEqualTypeOf< + RefLeaf >() return and(gt(count(user.id), 3), gt(avg(user.salary), 70000)) @@ -506,9 +506,9 @@ describe(`Query Builder Callback Types`, () => { }) .join({ project: projectsCollection }, ({ user, dept, project }) => { expectTypeOf(user.id).toEqualTypeOf>() - expectTypeOf(dept?.id).toEqualTypeOf | undefined>() - expectTypeOf(dept?.location).toEqualTypeOf< - RefLeaf | undefined + expectTypeOf(dept.id).toEqualTypeOf>() + expectTypeOf(dept.location).toEqualTypeOf< + RefLeaf >() expectTypeOf(project.user_id).toEqualTypeOf>() expectTypeOf(project.department_id).toEqualTypeOf>() @@ -516,57 +516,57 @@ describe(`Query Builder Callback Types`, () => { }) .where(({ user, dept, project }) => { expectTypeOf(user.id).toEqualTypeOf>() - expectTypeOf(dept?.id).toEqualTypeOf | undefined>() - expectTypeOf(dept?.location).toEqualTypeOf< - RefLeaf | undefined + expectTypeOf(dept.id).toEqualTypeOf>() + expectTypeOf(dept.location).toEqualTypeOf< + RefLeaf >() - expectTypeOf(project?.user_id).toEqualTypeOf< - RefLeaf | undefined + expectTypeOf(project.user_id).toEqualTypeOf< + RefLeaf >() - expectTypeOf(project?.department_id).toEqualTypeOf< - RefLeaf | undefined + expectTypeOf(project.department_id).toEqualTypeOf< + RefLeaf >() return and( eq(user.active, true), - eq(dept?.active, true), - eq(project?.status, `active`), + eq(dept.active, true), + eq(project.status, `active`), ) }) .groupBy(({ dept }) => { - expectTypeOf(dept?.id).toEqualTypeOf | undefined>() - expectTypeOf(dept?.location).toEqualTypeOf< - RefLeaf | undefined + expectTypeOf(dept.id).toEqualTypeOf>() + expectTypeOf(dept.location).toEqualTypeOf< + RefLeaf >() - return dept?.location + return dept.location }) .having(({ user, project }) => { expectTypeOf(user.id).toEqualTypeOf>() - expectTypeOf(project?.budget).toEqualTypeOf< - RefLeaf | undefined + expectTypeOf(project.budget).toEqualTypeOf< + RefLeaf >() - return and(gt(count(user.id), 2), gt(avg(project?.budget), 50000)) + return and(gt(count(user.id), 2), gt(avg(project.budget), 50000)) }) .select(({ user, dept, project }) => { expectTypeOf(user.id).toEqualTypeOf>() - expectTypeOf(dept?.location).toEqualTypeOf< - RefLeaf | undefined + expectTypeOf(dept.location).toEqualTypeOf< + RefLeaf >() - expectTypeOf(project?.budget).toEqualTypeOf< - RefLeaf | undefined + expectTypeOf(project.budget).toEqualTypeOf< + RefLeaf >() return { - location: dept?.location, + location: dept.location, user_count: count(user.id), avg_salary: avg(user.salary), - total_project_budget: sum(project?.budget), - avg_project_budget: avg(project?.budget), + total_project_budget: sum(project.budget), + avg_project_budget: avg(project.budget), } }) .orderBy(({ dept }) => { - expectTypeOf(dept?.location).toEqualTypeOf< - RefLeaf | undefined + expectTypeOf(dept.location).toEqualTypeOf< + RefLeaf >() - return dept?.location + return dept.location }) }) }) diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 3c73efa93..5005c0e1a 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -3,6 +3,7 @@ import { Temporal } from 'temporal-polyfill' import { createCollection } from '../../src/collection/index.js' import { and, + coalesce, createLiveQueryCollection, eq, ilike, @@ -2284,7 +2285,7 @@ describe(`createLiveQueryCollection`, () => { .select(({ base: b, related: r }) => ({ id: b.id, name: b.name, - value: r?.value, + value: r.value, })), getKey: (item) => item.id, // Valid for 1:1 joins with unique keys }) @@ -2324,9 +2325,9 @@ describe(`createLiveQueryCollection`, () => { .join({ users }, ({ comments: c, users: u }) => eq(c.userId, u.id)) .select(({ comments: c, users: u }) => ({ id: c.id, - userId: u?.id ?? c.userId, + userId: coalesce(u.id, c.userId), text: c.text, - userName: u?.name, + userName: u.name, })), getKey: (item) => item.userId, startSync: true, From e7ca2ddb06edfe8ca03c12bcfc79fe61a48f081d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:05:27 +0000 Subject: [PATCH 4/5] ci: apply automated fixes --- packages/db/src/query/builder/types.ts | 12 ++--- .../query/builder/callback-types.test-d.ts | 52 +++++-------------- 2 files changed, 18 insertions(+), 46 deletions(-) diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index b4639fdb8..6dce531f8 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -240,9 +240,7 @@ export type ResultTypeFromSelect = WithoutRefBrand< ? T | undefined : // RefLeaf | null (schema-nullable field) TSelectObject[K] extends RefLeaf | null - ? IsNullableRef< - Exclude - > extends true + ? IsNullableRef> extends true ? T | null | undefined : T | null : // Ref | undefined (optional object-type schema field) @@ -399,11 +397,11 @@ export type RefsForContext = { > extends true ? IsNonExactNullable extends true ? // T is both non-exact optional and non-exact nullable (e.g., string | null | undefined) - // Extract the non-undefined and non-null part, mark as nullable ref - Ref, true> + // Extract the non-undefined and non-null part, mark as nullable ref + Ref, true> : // T is optional (T | undefined) but not exactly undefined, and not nullable - // Extract the non-undefined part, mark as nullable ref - Ref, true> + // Extract the non-undefined part, mark as nullable ref + Ref, true> : IsNonExactNullable extends true ? // T is nullable (T | null) but not exactly null, and not optional // Extract the non-null part, mark as nullable ref diff --git a/packages/db/tests/query/builder/callback-types.test-d.ts b/packages/db/tests/query/builder/callback-types.test-d.ts index edd606a54..52060e983 100644 --- a/packages/db/tests/query/builder/callback-types.test-d.ts +++ b/packages/db/tests/query/builder/callback-types.test-d.ts @@ -124,9 +124,7 @@ describe(`Query Builder Callback Types`, () => { ).toEqualTypeOf | null>() expectTypeOf(dept.id).toEqualTypeOf>() expectTypeOf(dept.name).toEqualTypeOf>() - expectTypeOf(dept.budget).toEqualTypeOf< - RefLeaf - >() + expectTypeOf(dept.budget).toEqualTypeOf>() return { user_name: user.name, @@ -263,12 +261,8 @@ describe(`Query Builder Callback Types`, () => { ) .where(({ user, dept }) => { expectTypeOf(user.active).toEqualTypeOf>() - expectTypeOf(dept.active).toEqualTypeOf< - RefLeaf - >() - expectTypeOf(dept.budget).toEqualTypeOf< - RefLeaf - >() + expectTypeOf(dept.active).toEqualTypeOf>() + expectTypeOf(dept.budget).toEqualTypeOf>() return and( eq(user.active, true), @@ -401,9 +395,7 @@ describe(`Query Builder Callback Types`, () => { .groupBy(({ user, dept }) => { expectTypeOf(user.id).toEqualTypeOf>() expectTypeOf(dept.id).toEqualTypeOf>() - expectTypeOf(dept.location).toEqualTypeOf< - RefLeaf - >() + expectTypeOf(dept.location).toEqualTypeOf>() return dept.location }) @@ -485,9 +477,7 @@ describe(`Query Builder Callback Types`, () => { .having(({ user, dept }) => { expectTypeOf(user.id).toEqualTypeOf>() expectTypeOf(dept.id).toEqualTypeOf>() - expectTypeOf(dept.location).toEqualTypeOf< - RefLeaf - >() + expectTypeOf(dept.location).toEqualTypeOf>() return and(gt(count(user.id), 3), gt(avg(user.salary), 70000)) }) @@ -507,9 +497,7 @@ describe(`Query Builder Callback Types`, () => { .join({ project: projectsCollection }, ({ user, dept, project }) => { expectTypeOf(user.id).toEqualTypeOf>() expectTypeOf(dept.id).toEqualTypeOf>() - expectTypeOf(dept.location).toEqualTypeOf< - RefLeaf - >() + expectTypeOf(dept.location).toEqualTypeOf>() expectTypeOf(project.user_id).toEqualTypeOf>() expectTypeOf(project.department_id).toEqualTypeOf>() return eq(project.user_id, user.id) @@ -517,12 +505,8 @@ describe(`Query Builder Callback Types`, () => { .where(({ user, dept, project }) => { expectTypeOf(user.id).toEqualTypeOf>() expectTypeOf(dept.id).toEqualTypeOf>() - expectTypeOf(dept.location).toEqualTypeOf< - RefLeaf - >() - expectTypeOf(project.user_id).toEqualTypeOf< - RefLeaf - >() + expectTypeOf(dept.location).toEqualTypeOf>() + expectTypeOf(project.user_id).toEqualTypeOf>() expectTypeOf(project.department_id).toEqualTypeOf< RefLeaf >() @@ -534,26 +518,18 @@ describe(`Query Builder Callback Types`, () => { }) .groupBy(({ dept }) => { expectTypeOf(dept.id).toEqualTypeOf>() - expectTypeOf(dept.location).toEqualTypeOf< - RefLeaf - >() + expectTypeOf(dept.location).toEqualTypeOf>() return dept.location }) .having(({ user, project }) => { expectTypeOf(user.id).toEqualTypeOf>() - expectTypeOf(project.budget).toEqualTypeOf< - RefLeaf - >() + expectTypeOf(project.budget).toEqualTypeOf>() return and(gt(count(user.id), 2), gt(avg(project.budget), 50000)) }) .select(({ user, dept, project }) => { expectTypeOf(user.id).toEqualTypeOf>() - expectTypeOf(dept.location).toEqualTypeOf< - RefLeaf - >() - expectTypeOf(project.budget).toEqualTypeOf< - RefLeaf - >() + expectTypeOf(dept.location).toEqualTypeOf>() + expectTypeOf(project.budget).toEqualTypeOf>() return { location: dept.location, user_count: count(user.id), @@ -563,9 +539,7 @@ describe(`Query Builder Callback Types`, () => { } }) .orderBy(({ dept }) => { - expectTypeOf(dept.location).toEqualTypeOf< - RefLeaf - >() + expectTypeOf(dept.location).toEqualTypeOf>() return dept.location }) }) From 7c94a5f2069ca19bb060fb10a780bafe6097c3ea Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 18 Feb 2026 14:11:15 +0100 Subject: [PATCH 5/5] chore: add changeset for nullable join ref typing fix Co-Authored-By: Claude Opus 4.6 --- .changeset/fix-nullable-join-select-ref-typing.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/fix-nullable-join-select-ref-typing.md diff --git a/.changeset/fix-nullable-join-select-ref-typing.md b/.changeset/fix-nullable-join-select-ref-typing.md new file mode 100644 index 000000000..0c6a09e0e --- /dev/null +++ b/.changeset/fix-nullable-join-select-ref-typing.md @@ -0,0 +1,7 @@ +--- +'@tanstack/db': patch +--- + +fix(db): use `Ref` brand instead of `Ref | undefined` for nullable join refs in declarative select + +The declarative `select()` callback receives proxy objects that record property accesses. These proxies are always truthy at build time, but nullable join sides (left/right/full) were typed as `Ref | undefined`, misleading users into using `?.` and `??` operators that have no effect at runtime. Nullable join refs are now typed as `Ref`, which allows direct property access without optional chaining while correctly producing `T | undefined` in the result type.