Skip to content

Commit 0950952

Browse files
committed
fix(db): preserve null in coalesce() return type when no guaranteed non-null arg
The previous implementation used NonNullable<ExtractType<T>> on just the first arg, stripping null from the return type unconditionally. This was unsound because the runtime evaluator returns null when all args are null. New behavior: - Strip null only when at least one arg is statically guaranteed non-null (does not include null | undefined in its type) - Keep null in the result type when all args could be null/undefined Examples: coalesce(user.name, 'Unknown') → BasicExpression<string> (non-null literal fallback) coalesce(user.department_id) → BasicExpression<number | null> (nullable only) coalesce(user.department_id, 0) → BasicExpression<number> (0 is guaranteed non-null) coalesce(user.department_id, dept?.id) → BasicExpression<number | null> (dept?.id can be undefined) Added type tests for the nullable-only and guaranteed-non-null cases. Addresses review feedback from samwillis.
1 parent 34eb4bf commit 0950952

File tree

2 files changed

+41
-6
lines changed

2 files changed

+41
-6
lines changed

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

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -285,14 +285,37 @@ export function concat(
285285
)
286286
}
287287

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

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

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,18 @@ describe(`Query Builder Callback Types`, () => {
152152
expectTypeOf(coalesce(user.name, `Unknown`)).toEqualTypeOf<
153153
BasicExpression<string>
154154
>()
155+
// nullable-only args: null retained in result
156+
expectTypeOf(coalesce(user.department_id)).toEqualTypeOf<
157+
BasicExpression<number | null>
158+
>()
159+
// nullable args only: null retained (dept?.id can be undefined)
160+
expectTypeOf(
161+
coalesce(user.department_id, user.department_id),
162+
).toEqualTypeOf<BasicExpression<number | null>>()
163+
// guaranteed non-null fallback: null stripped
164+
expectTypeOf(coalesce(user.department_id, 0)).toEqualTypeOf<
165+
BasicExpression<number>
166+
>()
155167

156168
return {
157169
upper_name: upper(user.name),

0 commit comments

Comments
 (0)