Skip to content
Merged
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
45 changes: 45 additions & 0 deletions .changeset/fix-findone-joins-type-inference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@tanstack/db": patch
---

Fix type inference for findOne() when used with join operations

Previously, using `findOne()` with join operations (leftJoin, innerJoin, etc.) resulted in the query type being inferred as `never`, breaking TypeScript type checking:

```typescript
const query = useLiveQuery(
(q) =>
q
.from({ todo: todoCollection })
.leftJoin({ todoOptions: todoOptionsCollection }, ...)
.findOne() // Type became 'never'
)
```

**The Fix:**

Fixed the `MergeContextWithJoinType` type definition to conditionally include the `singleResult` property only when it's explicitly `true`, avoiding type conflicts when `findOne()` is called after joins:

```typescript
// Before (buggy):
singleResult: TContext['singleResult'] extends true ? true : false

// After (fixed):
type PreserveSingleResultFlag<TFlag> = [TFlag] extends [true]
? { singleResult: true }
: {}

// Used as:
} & PreserveSingleResultFlag<TContext['singleResult']>
```

**Why This Works:**

By using a conditional intersection that omits the property entirely when not needed, we avoid type conflicts. Intersecting `{} & { singleResult: true }` cleanly results in `{ singleResult: true }`, whereas the previous approach created conflicting property types resulting in `never`. The tuple wrapper (`[TFlag]`) ensures robust behavior even if the flag type becomes a union in the future.

**Impact:**

- ✅ `findOne()` now works correctly with all join types
- ✅ Type inference works properly in `useLiveQuery` and other contexts
- ✅ Both `findOne()` before and after joins work correctly
- ✅ All tests pass with no breaking changes (8 new type tests added)
8 changes: 7 additions & 1 deletion .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,13 @@ jobs:
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "Error: Generated docs are out of sync!"
echo "Please run 'pnpm docs:generate' locally and commit the changes."
echo ""
echo "Please run the following commands locally and commit the changes:"
echo " 1. pnpm install"
echo " 2. pnpm build"
echo " 3. pnpm docs:generate"
echo " 4. git add docs/"
echo " 5. git commit -m 'docs: regenerate API documentation'"
echo ""
echo "Files that need to be updated:"
git status --short
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/type-aliases/GetResult.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ title: GetResult
type GetResult<TContext> = Prettify<TContext["result"] extends object ? TContext["result"] : TContext["hasJoins"] extends true ? TContext["schema"] : TContext["schema"][TContext["fromSourceName"]]>;
```

Defined in: [packages/db/src/query/builder/types.ts:661](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/types.ts#L661)
Defined in: [packages/db/src/query/builder/types.ts:675](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/types.ts#L675)

GetResult - Determines the final result type of a query

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/type-aliases/InferResultType.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ title: InferResultType
type InferResultType<TContext> = TContext extends SingleResult ? GetResult<TContext> | undefined : GetResult<TContext>[];
```

Defined in: [packages/db/src/query/builder/types.ts:631](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/types.ts#L631)
Defined in: [packages/db/src/query/builder/types.ts:645](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/types.ts#L645)

Utility type to infer the query result size (single row or an array)

Expand Down
18 changes: 16 additions & 2 deletions packages/db/src/query/builder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,20 @@ export type RefLeaf<T = any> = { readonly [RefBrand]?: T }
type WithoutRefBrand<T> =
T extends Record<string, any> ? Omit<T, typeof RefBrand> : T

/**
* PreserveSingleResultFlag - Conditionally includes the singleResult flag
*
* This helper type ensures the singleResult flag is only added to the context when it's
* explicitly true. It uses a non-distributive conditional (tuple wrapper) to prevent
* unexpected behavior when TFlag is a union type.
*
* @template TFlag - The singleResult flag value to check
* @returns { singleResult: true } if TFlag is true, otherwise {}
*/
type PreserveSingleResultFlag<TFlag> = [TFlag] extends [true]
? { singleResult: true }
: {}

/**
* MergeContextWithJoinType - Creates a new context after a join operation
*
Expand All @@ -551,6 +565,7 @@ type WithoutRefBrand<T> =
* - `hasJoins`: Set to true
* - `joinTypes`: Updated to track this join type
* - `result`: Preserved from previous operations
* - `singleResult`: Preserved only if already true (via PreserveSingleResultFlag)
*/
export type MergeContextWithJoinType<
TContext extends Context,
Expand All @@ -574,8 +589,7 @@ export type MergeContextWithJoinType<
[K in keyof TNewSchema & string]: TJoinType
}
result: TContext[`result`]
singleResult: TContext[`singleResult`] extends true ? true : false
}
} & PreserveSingleResultFlag<TContext[`singleResult`]>

/**
* ApplyJoinOptionalityToMergedSchema - Applies optionality rules when merging schemas
Expand Down
248 changes: 248 additions & 0 deletions packages/db/tests/query/findone-joins.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import { describe, expectTypeOf, test } from "vitest"
import { createLiveQueryCollection, eq } from "../../src/query/index.js"
import { createCollection } from "../../src/collection/index.js"
import { mockSyncCollectionOptions } from "../utils.js"

type Todo = {
id: string
text: string
order: number
}

type TodoOption = {
id: string
todoId: string
optionText: string
}

const todoCollection = createCollection(
mockSyncCollectionOptions<Todo>({
id: `test-todos-findone-joins`,
getKey: (todo) => todo.id,
initialData: [],
})
)

const todoOptionsCollection = createCollection(
mockSyncCollectionOptions<TodoOption>({
id: `test-todo-options-findone-joins`,
getKey: (opt) => opt.id,
initialData: [],
})
)

describe(`findOne() with joins`, () => {
test(`findOne() after leftJoin should infer correct types`, () => {
const query = createLiveQueryCollection({
query: (q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.id, `test-id`))
.orderBy(({ todo }) => todo.order, `asc`)
.leftJoin(
{ todoOptions: todoOptionsCollection },
({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId)
)
.findOne(),
})

expectTypeOf(query.toArray).toEqualTypeOf<
Array<{
todo: Todo
todoOptions: TodoOption | undefined
}>
>()
})

test(`findOne() with innerJoin should infer correct types`, () => {
const query = createLiveQueryCollection({
query: (q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.id, `test-id`))
.innerJoin(
{ todoOptions: todoOptionsCollection },
({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId)
)
.findOne(),
})

expectTypeOf(query.toArray).toEqualTypeOf<
Array<{
todo: Todo
todoOptions: TodoOption
}>
>()
})

test(`findOne() before join should infer correct types`, () => {
const query = createLiveQueryCollection({
query: (q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.id, `test-id`))
.findOne()
.leftJoin(
{ todoOptions: todoOptionsCollection },
({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId)
),
})

expectTypeOf(query.toArray).toEqualTypeOf<
Array<{
todo: Todo
todoOptions: TodoOption | undefined
}>
>()
})

test(`findOne() with rightJoin should infer correct types`, () => {
const query = createLiveQueryCollection({
query: (q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.id, `test-id`))
.rightJoin(
{ todoOptions: todoOptionsCollection },
({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId)
)
.findOne(),
})

expectTypeOf(query.toArray).toEqualTypeOf<
Array<{
todo: Todo | undefined
todoOptions: TodoOption
}>
>()
})

test(`findOne() with fullJoin should infer correct types`, () => {
const query = createLiveQueryCollection({
query: (q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.id, `test-id`))
.fullJoin(
{ todoOptions: todoOptionsCollection },
({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId)
)
.findOne(),
})

expectTypeOf(query.toArray).toEqualTypeOf<
Array<{
todo: Todo | undefined
todoOptions: TodoOption | undefined
}>
>()
})

test(`findOne() with multiple joins should infer correct types`, () => {
type TodoTag = {
id: string
todoId: string
tag: string
}

const todoTagsCollection = createCollection(
mockSyncCollectionOptions<TodoTag>({
id: `test-todo-tags-findone-multi`,
getKey: (tag) => tag.id,
initialData: [],
})
)

const query = createLiveQueryCollection({
query: (q) =>
q
.from({ todo: todoCollection })
.leftJoin(
{ todoOptions: todoOptionsCollection },
({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId)
)
.innerJoin({ tag: todoTagsCollection }, ({ todo, tag }) =>
eq(todo.id, tag.todoId)
)
.findOne(),
})

expectTypeOf(query.toArray).toEqualTypeOf<
Array<{
todo: Todo
todoOptions: TodoOption | undefined
tag: TodoTag
}>
>()
})

test(`findOne() with select() and joins should infer correct types`, () => {
const query = createLiveQueryCollection({
query: (q) =>
q
.from({ todo: todoCollection })
.leftJoin(
{ todoOptions: todoOptionsCollection },
({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId)
)
.select(({ todo, todoOptions }) => ({
todoText: todo.text,
optionText: todoOptions?.optionText,
}))
.findOne(),
})

expectTypeOf(query.toArray).toEqualTypeOf<
Array<{
todoText: string
optionText: string | undefined
}>
>()
})

test(`findOne() before select() with joins should infer correct types`, () => {
const query = createLiveQueryCollection({
query: (q) =>
q
.from({ todo: todoCollection })
.leftJoin(
{ todoOptions: todoOptionsCollection },
({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId)
)
.findOne()
.select(({ todo, todoOptions }) => ({
todoText: todo.text,
optionText: todoOptions?.optionText,
})),
})

expectTypeOf(query.toArray).toEqualTypeOf<
Array<{
todoText: string
optionText: string | undefined
}>
>()
})

test(`limit(1) should infer array type`, () => {
const query = createLiveQueryCollection({
query: (q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.id, `test-id`))
.orderBy(({ todo }) => todo.order, `asc`)
.leftJoin(
{ todoOptions: todoOptionsCollection },
({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId)
)
.limit(1),
})

expectTypeOf(query.toArray).toEqualTypeOf<
Array<{
todo: Todo
todoOptions: TodoOption | undefined
}>
>()
})
})
Loading