Skip to content

Commit 5613b5a

Browse files
committed
Fix: findOne() type inference with joins
## Problem When using findOne() after join operations, the type of query.data became 'never', breaking TypeScript type inference. This was reported on Discord where users found that limit(1) worked but findOne() did not. ## Root Cause In MergeContextWithJoinType, the singleResult property was being explicitly set to false when not true: singleResult: TContext['singleResult'] extends true ? true : false This caused a type conflict when findOne() later tried to intersect with SingleResult: { singleResult: false } & { singleResult: true } = { singleResult: never } ## Solution Changed to preserve the singleResult value as-is: singleResult: TContext['singleResult'] This allows: - findOne() before join: singleResult stays true - findOne() after join: singleResult is undefined, which properly intersects with { singleResult: true } ## Changes - Fixed type definition in packages/db/src/query/builder/types.ts:577 - Added 8 comprehensive type tests for findOne() with all join types - Added investigation documentation ## Test Coverage - findOne() with leftJoin, innerJoin, rightJoin, fullJoin - findOne() with multiple joins - findOne() with select - findOne() before vs after joins - limit(1) vs findOne() type differences
1 parent 48b8e8f commit 5613b5a

3 files changed

Lines changed: 407 additions & 1 deletion

File tree

FINDONE_JOINS_BUG_INVESTIGATION.md

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# Investigation: findOne() with joins Type Inference Bug
2+
3+
## Problem Summary
4+
5+
When using `findOne()` after join operations in `useLiveQuery`, the type of `query.data` becomes `never`, causing TypeScript errors. This issue was reported on Discord.
6+
7+
### Example Code (Bug)
8+
9+
```typescript
10+
const query = useLiveQuery(
11+
(q) =>
12+
q
13+
.from({ todo: todoCollection })
14+
.where(({ todo }) => eq(todo.id, id))
15+
.leftJoin({ todoOptions: todoOptionsCollection }, ({ todo, todoOptions }) =>
16+
eq(todo.id, todoOptions.todoId)
17+
)
18+
.findOne() // ❌ Causes type of query.data to become never
19+
);
20+
```
21+
22+
### Workaround
23+
24+
Using `limit(1)` instead of `findOne()` worked correctly:
25+
26+
```typescript
27+
const query = useLiveQuery(
28+
(q) =>
29+
q
30+
.from({ todo: todoCollection })
31+
.where(({ todo }) => eq(todo.id, id))
32+
.leftJoin({ todoOptions: todoOptionsCollection }, ({ todo, todoOptions }) =>
33+
eq(todo.id, todoOptions.todoId)
34+
)
35+
.limit(1) // ✅ Works correctly
36+
);
37+
```
38+
39+
## Root Cause Analysis
40+
41+
### Type Flow Investigation
42+
43+
1. **After `from()`**:
44+
- Context has `singleResult: undefined` (property not set)
45+
46+
2. **After `leftJoin()`**:
47+
- `MergeContextWithJoinType` was explicitly setting `singleResult: false`
48+
- Line 577 in `/packages/db/src/query/builder/types.ts`:
49+
```typescript
50+
singleResult: TContext['singleResult'] extends true ? true : false
51+
```
52+
- This forces `singleResult` to `false` when it's not explicitly `true`
53+
54+
3. **After `findOne()`**:
55+
- `findOne()` returns `QueryBuilder<TContext & SingleResult>`
56+
- This creates an intersection: `{ singleResult: false } & { singleResult: true }`
57+
- TypeScript resolves this conflict as `{ singleResult: never }`
58+
- The `never` type propagates through the type system, breaking type inference
59+
60+
### The Bug
61+
62+
The issue was in the `MergeContextWithJoinType` type definition, which was forcing `singleResult` to be explicitly `false` instead of preserving its original value.
63+
64+
**Before (Buggy)**:
65+
```typescript
66+
export type MergeContextWithJoinType<
67+
TContext extends Context,
68+
TNewSchema extends ContextSchema,
69+
TJoinType extends `inner` | `left` | `right` | `full` | `outer` | `cross`,
70+
> = {
71+
baseSchema: TContext[`baseSchema`]
72+
schema: ApplyJoinOptionalityToMergedSchema<...>
73+
fromSourceName: TContext[`fromSourceName`]
74+
hasJoins: true
75+
joinTypes: (TContext[`joinTypes`] extends Record<string, any>
76+
? TContext[`joinTypes`]
77+
: {}) & {
78+
[K in keyof TNewSchema & string]: TJoinType
79+
}
80+
result: TContext[`result`]
81+
singleResult: TContext[`singleResult`] extends true ? true : false // ❌ BUG HERE
82+
}
83+
```
84+
85+
## The Fix
86+
87+
Changed line 577 in `/packages/db/src/query/builder/types.ts` to preserve the `singleResult` value as-is:
88+
89+
**After (Fixed)**:
90+
```typescript
91+
export type MergeContextWithJoinType<
92+
TContext extends Context,
93+
TNewSchema extends ContextSchema,
94+
TJoinType extends `inner` | `left` | `right` | `full` | `outer` | `cross`,
95+
> = {
96+
baseSchema: TContext[`baseSchema`]
97+
schema: ApplyJoinOptionalityToMergedSchema<...>
98+
fromSourceName: TContext[`fromSourceName`]
99+
hasJoins: true
100+
joinTypes: (TContext[`joinTypes`] extends Record<string, any>
101+
? TContext[`joinTypes`]
102+
: {}) & {
103+
[K in keyof TNewSchema & string]: TJoinType
104+
}
105+
result: TContext[`result`]
106+
singleResult: TContext[`singleResult`] // ✅ FIXED: Preserve value as-is
107+
}
108+
```
109+
110+
### Why This Works
111+
112+
By preserving `singleResult` as-is:
113+
- If `findOne()` is called **before** join: `singleResult` is `true` and stays `true`
114+
- If `findOne()` is called **after** join: `singleResult` is `undefined` and the intersection `undefined & { singleResult: true }` properly resolves to `{ singleResult: true }`
115+
- No type conflict occurs
116+
117+
## Test Coverage Added
118+
119+
Added comprehensive type tests in `/packages/db/tests/query/join.test-d.ts`:
120+
121+
1. `findOne()` with `leftJoin` - returns single result with optional right table
122+
2. `findOne()` with `innerJoin` - returns single result with both tables required
123+
3. `findOne()` with `rightJoin` - returns single result with optional left table
124+
4. `findOne()` with `fullJoin` - returns single result with both tables optional
125+
5. `findOne()` with multiple joins - handles complex optionality correctly
126+
6. `findOne()` with join and select - projects correctly
127+
7. `findOne()` before join - works correctly in reverse order
128+
8. `limit(1)` vs `findOne()` - confirms different return types
129+
130+
## Impact
131+
132+
This fix ensures that:
133+
-`findOne()` works correctly with all join types (left, right, inner, full)
134+
-Type inference works correctly for `query.data` in `useLiveQuery`
135+
-No breaking changes to existing code
136+
-Both `findOne()` before and after joins work correctly
137+
138+
## Files Modified
139+
140+
1. `/packages/db/src/query/builder/types.ts` (line 577) - Fixed type definition
141+
2. `/packages/db/tests/query/join.test-d.ts` - Added 8 new type tests for findOne with joins
142+
143+
## Related Types
144+
145+
- `SingleResult`: `{ singleResult: true }`
146+
- `NonSingleResult`: `{ singleResult?: never }`
147+
- `Context`: Interface with optional `singleResult?: boolean`
148+
- `InferResultType<TContext>`: Determines if result is `T | undefined` or `Array<T>`

packages/db/src/query/builder/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ export type MergeContextWithJoinType<
574574
[K in keyof TNewSchema & string]: TJoinType
575575
}
576576
result: TContext[`result`]
577-
singleResult: TContext[`singleResult`] extends true ? true : false
577+
singleResult: TContext[`singleResult`]
578578
}
579579

580580
/**

0 commit comments

Comments
 (0)