Skip to content

Generic query constraints are lost across nullable refs and branch unions #1679

Description

@KyleAMathews

Summary

PR #1678 fixes the first layer of #1677: callbacks over Collection<T> regain keys guaranteed by an unresolved generic constraint such as T extends { id: string }.

Red/green verification uncovered two additional layers where the same constraint is still lost, plus a compatibility edge case in the straightforward nullable-ref fix.

The desired invariant is:

If T extends { id: string }, every query-builder callback should retain access to id, regardless of joins or unions, while preserving existing concrete and exact-nullish ref semantics.

Reproductions

Tested with TypeScript 5.9.3 under the @tanstack/db Vitest typechecker.

1. Nullable generic aliases after leftJoin

function leftJoinGeneric<T extends { id: string }>(
  a: Collection<{ id: string }, string>,
  b: Collection<T, string>,
  id: string,
) {
  return createLiveQueryCollection((q) =>
    q
      .from({ a })
      .leftJoin({ b }, ({ a, b }) => eq(a.id, b.id))
      .where(({ b }) => eq(b.id, id))
      .select(({ b }) => ({ id: b.id })),
  )
}

Both post-join b.id accesses fail. The ref type contains a propertyless RefLeaf<undefined, true> branch.

2. Source-form unionAll

function unionSourceGeneric<T extends { id: string }>(
  a: Collection<T, string>,
  b: Collection<T, string>,
) {
  return createLiveQueryCollection((q) =>
    q.unionAll({ a, b }).where(({ a, b }) => eq(a.id, b.id)),
  )
}

Both a.id and b.id fail for the same nullable-ref reason: source unions add | undefined to each alias.

3. Branch-form unionAll

function unionGeneric<T extends { id: string }>(
  a: Collection<T, string>,
  b: Collection<T, string>,
  id: string,
) {
  return createLiveQueryCollection((q) => {
    const aRows = q.from({ a })
    const bRows = q.from({ b })

    return q.unionAll(aRows, bRows).where(({ id: idRef }) => eq(idRef, id))
  })
}

The where callback has no id. ResultFromBranch uses GetResult, and UnionBranchSchema then performs another mapped reconstruction. The generic constraint is gone before callback refs are built.

Confirmed partial fix and compatibility edge case

For cases 1 and 2, restructuring RefsForContext to strip null | undefined before RefForContextValue distributes fixes the generic failures:

type RefForContextSchemaValue<T> = RefForContextValue<
  NonNullable<T>,
  IsOptional<T> extends true
    ? true
    : IsNullable<T> extends true
      ? true
      : false
>

This correctly represents the build-time proxy as present and propagates result nullability through the ref's Nullable flag.

However, unconditional NonNullable<T> changes exact null and exact undefined schema values from RefLeaf<null> / RefLeaf<undefined> to never. A focused type test confirms this regression.

Straightforward exact-nullish guards were also tested. They make the exact-nullish test green but leave the conditional unresolved for generic T | undefined, reintroducing propertyless branches and making cases 1 and 2 red again. A separate NullishKind<T> classifier has the same TypeScript deferral behavior.

Branch-union approaches tested

The following approaches did not provide a safe fix:

  1. Change branch extraction from GetResult to GetRawResult.

    • The subsequent UnionBranchSchema mapped reconstruction still loses generic keys.
  2. Use the raw branch union directly as refsSchema.

    • The generic key still does not reliably survive structural inference.
    • Existing heterogeneous branch behavior regresses.
  3. Intersect or union normalized UnionBranchSchema with raw branch results.

    • Generic id remains unavailable, or the raw union introduces optional/propertyless branches.
  4. Carry separate raw callback refs alongside normalized refs.

    • TypeScript reduces the structurally inferred raw generic branch to {} | Ref<T> at the callback boundary, so common properties remain inaccessible.
    • Propagating raw refs through joins breaks existing typed inner/right/full join tests and subquery compatibility.
  5. Add a phantom/nominal context property to QueryBuilder<TContext>.

    • Naively adding it changes assignability against QueryBuilder<any> throughout the API and produces widespread overload/variance failures.

Existing tests that must remain green include heterogeneous branch results and typed inner, right, and full joins in union-all.test-d.ts.

Likely design direction

The branch-form problem probably needs the query context/result type to be carried through QueryBuilder in a way that can be extracted nominally or covariantly, rather than recovered from its large structural method surface after GetResult/mapped reconstruction.

A robust design likely needs:

  • a raw, constraint-preserving branch result used only for immediate callback refs;
  • the existing normalized branch schema for consumer-facing results and heterogeneous optional keys;
  • an explicit transition back to normalized refs when later joins reshape the context; and
  • a QueryBuilder context carrier designed for assignability with QueryBuilder<any>, rather than a simple invariant phantom property.

Separately, nullable-ref construction needs an exact-nullish-safe formulation that TypeScript can resolve for unresolved T | null | undefined without leaving a propertyless union branch.

Acceptance criteria

  • Direct generic where, select, and subquery-source cases from Since 0.6.6, queries don't typecheck when the collection's row type is a generic type parameter #1677 remain green.
  • Generic aliases remain usable after a nullable join.
  • Generic aliases remain usable in source-form unionAll.
  • Generic fields remain usable after branch-form unionAll.
  • Exact null and exact undefined continue producing RefLeaf<null> and RefLeaf<undefined>, not never.
  • Existing concrete and heterogeneous unionAll tests remain green, including typed inner/right/full joins and joined subquery branches.

Related: #1677, #1678.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions