Skip to content

LEFT JOIN: select() callback receives truthy proxy objects for unmatched rows instead of null/undefined #1251

@marbemac

Description

@marbemac
  • I've validated the bug against the latest version of DB packages

Describe the bug

When using leftJoin, the select() callback receives proxy objects for unmatched (right-side) rows instead of null or undefined. These proxy objects are truthy, so conditional checks like right ? { id: right.id } : null always take the truthy branch, even when there was no matching row on the right side of the join.

This means there's no way to distinguish matched from unmatched rows inside select(). Accessing fields on these proxy objects (e.g. right.name) also returns truthy empty objects {} rather than undefined, so even right?.id ? ... : null doesn't work.

To Reproduce

import { createCollection, localOnlyCollectionOptions, createLiveQueryCollection, eq, Query } from '@tanstack/db';
import { z } from 'zod';

const users = createCollection(
  localOnlyCollectionOptions({
    id: 'users',
    getKey: (item: { id: string }) => item.id,
    schema: z.object({ id: z.string(), name: z.string() }),
    initialData: [
      { id: 'u1', name: 'Alice' },
      { id: 'u2', name: 'Bob' },
    ],
  }),
);

const profiles = createCollection(
  localOnlyCollectionOptions({
    id: 'profiles',
    getKey: (item: { id: string }) => item.id,
    schema: z.object({ id: z.string(), userId: z.string(), bio: z.string() }),
    initialData: [
      { id: 'p1', userId: 'u1', bio: 'Hello world' },
      // No profile for u2
    ],
  }),
);

const lq = createLiveQueryCollection({
  startSync: true,
  query: (q) =>
    q
      .from({ user: users })
      .leftJoin({ profile: profiles }, ({ user, profile }) => eq(user.id, profile.userId))
      .select(({ user, profile }) => ({
        name: user.name,
        // This conditional NEVER takes the null branch because `profile` is always a truthy proxy object
        bio: profile ? profile.bio : null,
      })),
});

const data = lq.toArray;
console.log(data);
// Actual:   [{ name: 'Alice', bio: 'Hello world' }, { name: 'Bob', bio: {} }]
// Expected: [{ name: 'Alice', bio: 'Hello world' }, { name: 'Bob', bio: null }]

The key observation: console.log(profile) inside the select for the unmatched row logs {} (a truthy empty object), and console.log(profile?.bio) also logs {}.

Expected behavior

For unmatched rows in a LEFT JOIN, the right-side table reference in the select() callback should be null or undefined (matching the TypeScript types, which correctly type it as potentially undefined). This would allow standard null checks to work:

.select(({ user, profile }) => ({
  name: user.name,
  bio: profile ? profile.bio : null, // should produce null for unmatched rows
}))

Desktop:

  • OS: macOS 15
  • Runtime: Vite dev server / Chrome
  • TanStack DB version: latest

Additional context

This is related to but distinct from #685, which covers LEFT JOIN nullability issues in where() clauses. This bug is specifically about the select() callback receiving proxy objects that don't behave like the types suggest.

The TypeScript types are correct (they mark LEFT JOIN references as potentially undefined), but the runtime behavior doesn't match — the references are always truthy proxy objects regardless of whether a match exists.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions