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
Describe the bug
materialize(q…findOne())is documented and typed as resolving toT | 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 tonullinstead. That value is outside the inferred type, so every=== undefined/!== undefinedguard 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 reachesmaterializeIncludedValueat all, so it returns the raw projection placeholder.To Reproduce
Two collections, one live query, no framework adapter. Three posts isolate the three paths:
Output:
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 onmaterialize()("the parent receives a singleT | undefinedvalue —undefinedwhen no child matches") and the inferredResultTypeFromSelectoutput.Notes / related
nulland never becomes reactive") as Not reproduced — that verification used a bare subquery-in-select, which yields an empty childCollection, rather thanmaterialize(…findOne())with a null FK. Thenullsymptom is real on this path.ResultTypeFromSelecttyping aleftJoinwhole-objectRefas non-nullable when the runtime value isundefined. There the fix is in the types; here the types are already correct.Environment
@tanstack/db0.6.17@tanstack/react-db0.1.95 (repro above uses@tanstack/dbdirectly; no adapter involved)strictNullChecks: true