Skip to content

fix(bindx): classify readonly array columns as scalar fields - #103

Merged
matej21 merged 3 commits into
mainfrom
fix/list-scalar-column-field-types
Sep 9, 2026
Merged

fix(bindx): classify readonly array columns as scalar fields#103
matej21 merged 3 commits into
mainfrom
fix/list-scalar-column-field-types

Conversation

@matej21

@matej21 matej21 commented Sep 9, 2026

Copy link
Copy Markdown
Member

A native list/array scalar column — a field typed readonly T[] where T is a primitive or a string enum (a enumColumn(...).list(), or any .list() scalar) — was misclassified by the accessor field-type mapping and by the selection builder. The accessor exposed no FieldAccessor for it, so .value / .setValue did not exist on the type and reading or writing the column from createComponent explicit selection did not compile.

The runtime FieldHandle handles array columns fine. This is a types-only fix — no runtime behaviour changes.

What was actually misclassified

The generator emits scalar list columns as readonly T[] (packages/bindx-generator/src/utils.ts) and has-many relations as mutable Target[]. The key-set helpers in packages/bindx/src/handles/types.ts tested T[K] extends (infer U)[], which no readonly array matches — so a list column skipped the array branch entirely and fell through to the has-one branch.

The issue text says the column is "dropped from all three key sets". Measured, it is worse than that: it is classified as a has-one. Table below (verified by the assertions in this PR, run against the source before and after):

field type example before after
readonly ('a'|'b')[] .list() enum column HasOne Scalar
readonly string[] | null nullable list column HasOne Scalar
number[] mutable list column none of the three Scalar
JSONValue json column Scalar Scalar
boolean | null nullable scalar Scalar Scalar
id: string identifier Scalar Scalar
Target[] has-many HasMany HasMany
readonly Target[] readonly has-many HasOne HasMany
Target | null / Target has-one HasOne HasOne

The readonly Target[] row is a second, unreported half of the same bug: a hand-written readonly has-many was misread as a has-one, so its accessor had no .items / .map() / .add(). Nothing in the repo generates that shape today, which is why nobody hit it, but it is the same missing readonly.

The same mistake was in SelectionBuilderMethods (packages/bindx-client/src/selection/types.ts), which tested TEntity[K] extends Array<infer U>. A readonly list column missed that test and landed on HasOneMethod, so e.tags() with zero arguments failed with TS2554: Expected 1-4 arguments, but got 0. (The issue comment attributes this to HasManyMethod; the actual routing is HasOneMethod. Same root cause, same symptom.)

The fix

Every array-shaped conditional now uses the idiom that already existed correctly in packages/bindx-client/src/qb/inputTypes.ts:

NonNullable<T[K]> extends readonly (infer U)[]
	? IsPlainObject<U> extends true ? /* has-many */ : /* scalar array */
	: /* … */

Both halves are load-bearing:

  • readonly (infer U)[] matches mutable and readonly arrays, and NonNullable<...> makes a nullable column behave like its non-null form.
  • IsPlainObject<U>, not U extends object. U extends object distributes over a union, so a JSONValue-typed json column whose type includes readonly JSONValue[] would match the object members alone and be classified as a has-many. IsPlainObject collapses to boolean for such a union, and the extends true test correctly fails.

IsPlainObject was defined twice inside bindx-client and exported from neither. It is now one shared internal type in packages/bindx-client/src/utils/fieldShape.ts, exported from the package so bindx can use it. Deduplicating also fixed a latent difference between the two copies: the selection/queryTypes.ts version tested T extends Array<any>, so it answered true for a readonly array (a readonly array is an object) — which made FieldsWhere and EntityOrderBy produce a nested EntityWhere / EntityOrderBy for a readonly list column instead of a scalar condition.

Files changed

  • packages/bindx-client/src/utils/fieldShape.tsnew, the shared IsPlainObject.
  • packages/bindx-client/src/index.ts — export it.
  • packages/bindx-client/src/qb/inputTypes.ts — drop the local copy, import the shared one.
  • packages/bindx-client/src/selection/queryTypes.ts — drop the local copy; FieldsWhere, EntityOrderBy, ArrayItemType, IsArray made readonly-aware.
  • packages/bindx-client/src/selection/types.tsArrayItemType and SelectionBuilderMethods (a scalar list column now routes to ScalarMethod).
  • packages/bindx/src/handles/types.tsScalarKeys / HasManyKeys / HasOneKeys, EntityFields, FieldRefType, FieldAccessorType, including the nested ExtractNestedSelection<TSelected, K> extends (infer S)[] tests.
  • tests/typeSafety.test.ts — the reporters' repro plus the classification cases from the table.

Sites deliberately left alone: packages/bindx-ui/src/datagrid/columns/enum-column.tsx already matches readonly (infer U)[] | null, and the readonly unknown[] generic constraints in packages/bindx-react/src/hooks/useFields.ts are tuple constraints, not field classification.

Inference ripple

One, found and fixed while iterating. Rewriting the has-many branch of FieldAccessorType as a direct HasManyAccessor<HasManyItem<TEntity[K]>, …> (no wrapping conditional) broke the pre-existing assertion in packages/bindx-react/src/jsx/proxyShared.ts:25 with TS2352: neither type sufficiently overlaps. That cast only type-checks while the has-many branch stays a deferred conditional for an unresolved TEntity; resolving it eagerly makes the comparison concrete and it fails. The branch is therefore kept in its original wrapped-conditional shape, just with readonly and IsPlainObject — which also keeps it consistent with the surrounding code. Nothing in proxyShared.ts had to change.

A correction to the reporters' second assertion

assertTrue<AssertExtends<GroupSizeField, FieldAccessor<readonly string[]>>>() cannot hold, and not because of arrays: FieldAccessor<T> is invariant in T, because inputProps.setValue is a function-typed property (contravariant under strictFunctionTypes) while value is covariant. FieldAccessor<'a' | 'b'> does not extend FieldAccessor<string> either — verified directly.

The assertion now compares against the column's own type and checks the two members the issue is about:

assertTrue<AssertExtends<GroupSizeField, FieldAccessor<Lesson['groupSize']>>>()
assertTrue<AssertExtends<GroupSizeField['value'], Lesson['groupSize'] | null>>()
assertTrue<AssertExtends<GroupSizeField['setValue'], (value: Lesson['groupSize'] | null) => void>>()

Verification

Every assertion in the new block was confirmed load-bearing: with the test file at its final state and only the packages/ changes reverted, tsc reports 12 errors across all of them; with the fix applied, none.

  • bun run typecheck — clean.
  • bun run test2018 pass, 0 fail, 7481 expect() calls, 209 files.
  • tests/browser not run (needs a live playground).

Fixes #58

🤖 Generated with Claude Code

https://claude.ai/code/session_01Euwkf2wtutqvtE4YRutU5R

jindrak02 and others added 3 commits September 9, 2026 14:19
A `.list()` scalar column is generated as `readonly T[]`, and no
`T[K] extends (infer U)[]` test matches a readonly array. Every
array-shaped conditional in the accessor field-type mapping and in the
selection builder therefore misread such a column: it fell through to
the has-one branch, so the accessor exposed no `FieldAccessor` (no
`.value` / `.setValue`) and `e.tags()` demanded a nested selection.
A readonly has-many (`readonly Target[]`) was misread the same way.

Align every site on the `NonNullable<T[K]> extends readonly (infer U)[]`
plus `IsPlainObject<U>` idiom that `qb/inputTypes.ts` already used, and
deduplicate `IsPlainObject` into `bindx-client/src/utils/fieldShape.ts`.
`IsPlainObject` rather than `U extends object` is load-bearing: the
latter distributes, so a JSON column whose type includes
`readonly JSONValue[]` would be classified as a has-many.

`FieldAccessor<T>` is invariant in `T`, so the reporter's assertion
against `FieldAccessor<readonly string[]>` could never hold for a narrowed
enum column; it now asserts against the column's own type plus explicit
`.value` / `.setValue` shapes.

Types only — no runtime behaviour changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Euwkf2wtutqvtE4YRutU5R
@matej21
matej21 merged commit c1e977f into main Sep 9, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

List/array scalar columns dropped from accessor field types (no .value/.setValue)

2 participants