Skip to content

materialize(q…findOne()) resolves to null, not undefined, when the correlation key is itself null #1706

Description

@Jaime02

Describe the bug

materialize(q…findOne()) is documented and typed as resolving to T | undefined. It does — but only when the correlated FK holds a value that matches no child row.

When the FK column is itself null, the field resolves to null instead. That value is outside the inferred type, so every === undefined / !== undefined guard silently falls through and the next property access throws.

The FK-set-but-unmatched path was tested when materialize() landed (#1504 lists "missing-match → undefined" among its runtime tests). The FK-is-null path never reaches materializeIncludedValue at all, so it returns the raw projection placeholder.

To Reproduce

Two collections, one live query, no framework adapter. Three posts isolate the three paths:

import {
  createCollection,
  createLiveQueryCollection,
  eq,
  localOnlyCollectionOptions,
  materialize,
} from "@tanstack/db";

type TAuthor = { id: number; name: string };
type TPost = { id: number; title: string; authorId: number | null };

const authorCollection = createCollection(
  localOnlyCollectionOptions<TAuthor>({
    getKey: (author) => author.id,
    initialData: [{ id: 1, name: "Ada" }],
  }),
);

const postCollection = createCollection(
  localOnlyCollectionOptions<TPost>({
    getKey: (post) => post.id,
    initialData: [
      { id: 1, title: "fk matches an author", authorId: 1 },
      { id: 2, title: "fk set, but no author matches", authorId: 999 },
      { id: 3, title: "fk is null", authorId: null },
    ],
  }),
);

const postsWithAuthor = createLiveQueryCollection((q) =>
  q.from({ post: postCollection }).select(({ post }) => ({
    ...post,
    author: materialize(
      q
        .from({ author: authorCollection })
        .where(({ author }) => eq(author.id, post.authorId))
        .findOne(),
    ),
  })),
);

await postsWithAuthor.preload();

for (const row of postsWithAuthor.toArray) {
  console.log(`post ${row.id} (${row.title})`);
  console.log(`  author = ${JSON.stringify(row.author)}`);
  console.log(`  typeof = ${row.author === null ? "null" : typeof row.author}`);  // This null check shows a type error, but during runtime, row.author may be null
}

// Inferred type is `TAuthor | undefined`. `null` is not part of it, so this
// annotation compiles cleanly even though row 3 makes it false at runtime.
const nullableAuthor: TAuthor | undefined = postsWithAuthor.toArray[2]?.author;
console.log(`row 3 typed \`TAuthor | undefined\`, actual: ${JSON.stringify(nullableAuthor)}`);

Output:

post 1 (fk matches an author)
  author = {"id":1,"name":"Ada","$synced":true,"$origin":"local","$key":1,"$collectionId":"…"}
  typeof = object
post 2 (fk set, but no author matches)
  author = undefined
  typeof = undefined
post 3 (fk is null)
  author = null          <-- type says this is impossible
  typeof = null
row 3 typed `TAuthor | undefined`, actual: null

Post 2 confirms the documented behaviour still works, so this is specifically the null-correlation-key path and not a general "empty result" problem.

Expected behavior

Post 3 resolves to undefined, matching both the JSDoc on materialize() ("the parent receives a single T | undefined value — undefined when no child matches") and the inferred ResultTypeFromSelect output.

Notes / related

Environment

  • @tanstack/db 0.6.17
  • @tanstack/react-db 0.1.95 (repro above uses @tanstack/db directly; no adapter involved)
  • TypeScript 7.0.2, strictNullChecks: true
  • Bun, Windows 11

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