Skip to content

Commit b8abc02

Browse files
fix(db): infer non-nullable return type from first arg in coalesce() (#1342)
* fix(db): infer type in coalesce() instead of returning BasicExpression<any> Previously coalesce() always returned BasicExpression<any>, losing type information. Now it uses a generic to infer the non-nullable union of all argument types, matching the semantic of SQL COALESCE. Fixes #1341 * fix(db): preserve null in coalesce() when no guaranteed non-null arg - Use HasGuaranteedNonNull<T> helper to track whether any arg statically cannot be null/undefined - Return CoalesceArgTypes<T> | null unless a non-null arg guarantees it - Update tests to use spread args (varargs) instead of array - Update changeset description Addresses samwillis review feedback and kevin-dp ExpressionLike concerns * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent c281b37 commit b8abc02

File tree

4 files changed

+47
-4
lines changed

4 files changed

+47
-4
lines changed

.changeset/fix-coalesce-type.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/db': patch
3+
---
4+
5+
fix(db): preserve null in coalesce() return type when no guaranteed non-null arg is present
6+
7+
`coalesce()` was typed as returning `BasicExpression<any>`, losing all type information. The signature now infers types from all arguments via tuple generics, returns the union of non-null arg types, and only removes nullability when at least one argument is statically guaranteed non-null.

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,11 +286,35 @@ export function concat(
286286
)
287287
}
288288

289-
export function coalesce(...args: Array<ExpressionLike>): BasicExpression<any> {
289+
// Helper type for coalesce: extracts non-nullish value types from all args
290+
type CoalesceArgTypes<T extends Array<ExpressionLike>> = {
291+
[K in keyof T]: NonNullable<ExtractType<T[K]>>
292+
}[number]
293+
294+
// Whether any arg in the tuple is statically guaranteed non-null (i.e., does not include null | undefined)
295+
type HasGuaranteedNonNull<T extends Array<ExpressionLike>> = {
296+
[K in keyof T]: null extends ExtractType<T[K]>
297+
? false
298+
: undefined extends ExtractType<T[K]>
299+
? false
300+
: true
301+
}[number] extends false
302+
? false
303+
: true
304+
305+
// coalesce() return type: union of all non-null arg types; null included unless a guaranteed non-null arg exists
306+
type CoalesceReturnType<T extends Array<ExpressionLike>> =
307+
HasGuaranteedNonNull<T> extends true
308+
? BasicExpression<CoalesceArgTypes<T>>
309+
: BasicExpression<CoalesceArgTypes<T> | null>
310+
311+
export function coalesce<T extends [ExpressionLike, ...Array<ExpressionLike>]>(
312+
...args: T
313+
): CoalesceReturnType<T> {
290314
return new Func(
291315
`coalesce`,
292316
args.map((arg) => toExpression(arg)),
293-
)
317+
) as CoalesceReturnType<T>
294318
}
295319

296320
export function add<T1 extends ExpressionLike, T2 extends ExpressionLike>(

packages/db/tests/query/builder/callback-types.test-d.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,19 @@ describe(`Query Builder Callback Types`, () => {
148148
BasicExpression<number>
149149
>()
150150
expectTypeOf(coalesce(user.name, `Unknown`)).toEqualTypeOf<
151-
BasicExpression<any>
151+
BasicExpression<string>
152+
>()
153+
// nullable-only: coalesce(nullable, nullable) → keeps null in return type
154+
expectTypeOf(
155+
coalesce(user.department_id, user.department_id),
156+
).toEqualTypeOf<BasicExpression<number | null>>()
157+
// nullable + nullable literal null → keeps null
158+
expectTypeOf(coalesce(user.department_id, null)).toEqualTypeOf<
159+
BasicExpression<number | null>
160+
>()
161+
// nullable + guaranteed non-null → strips null
162+
expectTypeOf(coalesce(user.department_id, 0)).toEqualTypeOf<
163+
BasicExpression<number>
152164
>()
153165

154166
return {

packages/db/tests/query/builder/functions.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ describe(`QueryBuilder Functions`, () => {
196196
.from({ employees: employeesCollection })
197197
.select(({ employees }) => ({
198198
id: employees.id,
199-
name_or_default: coalesce([employees.name, `Unknown`]),
199+
name_or_default: coalesce(employees.name, `Unknown`),
200200
}))
201201

202202
const builtQuery = getQueryIR(query)

0 commit comments

Comments
 (0)