Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-nullable-join-select-ref-typing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/db': patch
---

fix(db): use `Ref<T, Nullable>` brand instead of `Ref<T> | 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<T> | undefined`, misleading users into using `?.` and `??` operators that have no effect at runtime. Nullable join refs are now typed as `Ref<T, true>`, which allows direct property access without optional chaining while correctly producing `T | undefined` in the result type.
74 changes: 37 additions & 37 deletions packages/db-collection-e2e/src/suites/joins.suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ export function createJoinsTestSuite(getConfig: () => Promise<E2ETestConfig>) {
eq(user.id, post.userId),
)
.select(({ user, post }) => ({
id: post!.id,
id: post.id,
userName: user.name,
postTitle: post!.title,
postTitle: post.title,
})),
)

Expand All @@ -53,12 +53,12 @@ export function createJoinsTestSuite(getConfig: () => Promise<E2ETestConfig>) {
.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,
})),
)

Expand Down Expand Up @@ -88,9 +88,9 @@ export function createJoinsTestSuite(getConfig: () => Promise<E2ETestConfig>) {
eq(user.id, post.userId),
)
.select(({ user, post }) => ({
id: post!.id,
id: post.id,
userName: user.name,
postTitle: post!.title,
postTitle: post.title,
})),
)

Expand All @@ -117,12 +117,12 @@ export function createJoinsTestSuite(getConfig: () => Promise<E2ETestConfig>) {
.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,
})),
)

Expand All @@ -149,7 +149,7 @@ export function createJoinsTestSuite(getConfig: () => Promise<E2ETestConfig>) {
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()
Expand All @@ -166,13 +166,13 @@ export function createJoinsTestSuite(getConfig: () => Promise<E2ETestConfig>) {
.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,
})),
)

Expand All @@ -194,13 +194,13 @@ export function createJoinsTestSuite(getConfig: () => Promise<E2ETestConfig>) {
.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,
})),
)

Expand All @@ -225,16 +225,16 @@ export function createJoinsTestSuite(getConfig: () => Promise<E2ETestConfig>) {
.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,
})),
)

Expand Down Expand Up @@ -264,13 +264,13 @@ export function createJoinsTestSuite(getConfig: () => Promise<E2ETestConfig>) {
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,
})),
)

Expand All @@ -296,12 +296,12 @@ export function createJoinsTestSuite(getConfig: () => Promise<E2ETestConfig>) {
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,
})),
)

Expand All @@ -326,10 +326,10 @@ export function createJoinsTestSuite(getConfig: () => Promise<E2ETestConfig>) {
.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,
})),
)

Expand Down Expand Up @@ -359,7 +359,7 @@ export function createJoinsTestSuite(getConfig: () => Promise<E2ETestConfig>) {
.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
})),
)

Expand Down
114 changes: 64 additions & 50 deletions packages/db/src/query/builder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,18 +227,28 @@ export type ResultTypeFromSelect<TSelectObject> = WithoutRefBrand<
Prettify<{
[K in keyof TSelectObject]: NeedsExtraction<TSelectObject[K]> extends true
? ExtractExpressionType<TSelectObject[K]>
: TSelectObject[K] extends Ref<infer _T>
: // Ref (full object ref or spread with RefBrand) - recursively process properties
TSelectObject[K] extends Ref<infer _T>
? ExtractRef<TSelectObject[K]>
: TSelectObject[K] extends RefLeaf<infer T>
? T
: TSelectObject[K] extends RefLeaf<infer T> | undefined
: // RefLeaf (simple property ref like user.name)
TSelectObject[K] extends RefLeaf<infer T>
? IsNullableRef<TSelectObject[K]> extends true
? T | undefined
: TSelectObject[K] extends RefLeaf<infer T> | null
? T | null
: TSelectObject[K] extends Ref<infer _T> | undefined
? ExtractRef<TSelectObject[K]> | undefined
: TSelectObject[K] extends Ref<infer _T> | null
? ExtractRef<TSelectObject[K]> | null
: T
: // RefLeaf | undefined (schema-optional field)
TSelectObject[K] extends RefLeaf<infer T> | undefined
? T | undefined
: // RefLeaf | null (schema-nullable field)
TSelectObject[K] extends RefLeaf<infer T> | null
? IsNullableRef<Exclude<TSelectObject[K], null>> extends true
? T | null | undefined
: T | null
: // Ref | undefined (optional object-type schema field)
TSelectObject[K] extends Ref<infer _T> | undefined
? ExtractRef<Exclude<TSelectObject[K], undefined>> | undefined
: // Ref | null (nullable object-type schema field)
TSelectObject[K] extends Ref<infer _T> | null
? ExtractRef<Exclude<TSelectObject[K], null>> | null
: TSelectObject[K] extends Aggregate<infer T>
? T
: TSelectObject[K] extends
Expand Down Expand Up @@ -366,24 +376,17 @@ export type FunctionalHavingRow<TContext extends Context> = 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<T, true>` instead
* of `Ref<T> | 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>` → user.name works
* - Optional field: `RefProxy<User> | undefined` → user?.name works
* - Nullable field: `RefProxy<User> | null` → user?.name works
* - Both optional and nullable: `RefProxy<User> | undefined` → user?.name works
*
* The key insight is that `RefProxy<User | undefined>` would NOT allow `user?.name`
* because the undefined is "inside" the proxy, but `RefProxy<User> | 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>` → user.name works, result is T
* - Nullable join side: `Ref<User, true>` → 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.
Expand All @@ -394,17 +397,17 @@ export type RefsForContext<TContext extends Context> = {
> extends true
? IsNonExactNullable<TContext[`schema`][K]> 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<NonNullable<TContext[`schema`][K]>> | undefined
// Extract the non-undefined and non-null part, mark as nullable ref
Ref<NonNullable<TContext[`schema`][K]>, true>
: // T is optional (T | undefined) but not exactly undefined, and not nullable
// Extract the non-undefined part and place undefined outside
Ref<NonUndefined<TContext[`schema`][K]>> | undefined
// Extract the non-undefined part, mark as nullable ref
Ref<NonUndefined<TContext[`schema`][K]>, true>
: IsNonExactNullable<TContext[`schema`][K]> extends true
? // T is nullable (T | null) but not exactly null, and not optional
// Extract the non-null part and place null outside
Ref<NonNull<TContext[`schema`][K]>> | null
// Extract the non-null part, mark as nullable ref
Ref<NonNull<TContext[`schema`][K]>, 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[`schema`][K]>
} & (TContext[`result`] extends object
? { $selected: Ref<TContext[`result`]> }
Expand Down Expand Up @@ -479,41 +482,44 @@ type NonNull<T> = 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<number> - clean display
* users.profile?.bio // Ref<string> - 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<T = any> = {
export type Ref<T = any, Nullable extends boolean = false> = {
[K in keyof T]: IsNonExactOptional<T[K]> extends true
? IsNonExactNullable<T[K]> extends true
? // Both optional and nullable
IsPlainObject<NonNullable<T[K]>> extends true
? Ref<NonNullable<T[K]>> | undefined
: RefLeaf<NonNullable<T[K]>> | undefined
? Ref<NonNullable<T[K]>, Nullable> | undefined
: RefLeaf<NonNullable<T[K]>, Nullable> | undefined
: // Optional only
IsPlainObject<NonUndefined<T[K]>> extends true
? Ref<NonUndefined<T[K]>> | undefined
: RefLeaf<NonUndefined<T[K]>> | undefined
? Ref<NonUndefined<T[K]>, Nullable> | undefined
: RefLeaf<NonUndefined<T[K]>, Nullable> | undefined
: IsNonExactNullable<T[K]> extends true
? // Nullable only
IsPlainObject<NonNull<T[K]>> extends true
? Ref<NonNull<T[K]>> | null
: RefLeaf<NonNull<T[K]>> | null
? Ref<NonNull<T[K]>, Nullable> | null
: RefLeaf<NonNull<T[K]>, Nullable> | null
: // Required
IsPlainObject<T[K]> extends true
? Ref<T[K]>
: RefLeaf<T[K]>
} & RefLeaf<T>
? Ref<T[K], Nullable>
: RefLeaf<T[K], Nullable>
} & RefLeaf<T, Nullable>

/**
* Ref - The user-facing ref type with clean IDE display
Expand All @@ -527,11 +533,19 @@ export type Ref<T = any> = {
* - No internal properties like __refProxy, __path, __type are visible
*/
declare const RefBrand: unique symbol
export type RefLeaf<T = any> = { readonly [RefBrand]?: T }
declare const NullableBrand: unique symbol
export type RefLeaf<T = any, Nullable extends boolean = false> = {
readonly [RefBrand]?: T
} & ([Nullable] extends [true] ? { readonly [NullableBrand]?: true } : {})

// Detect NullableBrand by checking for the key's presence
type IsNullableRef<T> = 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> =
T extends Record<string, any> ? Omit<T, typeof RefBrand> : T
T extends Record<string, any>
? Omit<T, typeof RefBrand | typeof NullableBrand>
: T

/**
* PreserveSingleResultFlag - Conditionally includes the singleResult flag
Expand Down
Loading
Loading