Skip to content

feat(bindx): add selection-erased entity view types - #83

Closed
matej21 wants to merge 1 commit into
mainfrom
feat/selection-erased-view-types
Closed

feat(bindx): add selection-erased entity view types#83
matej21 wants to merge 1 commit into
mainfrom
feat/selection-erased-view-types

Conversation

@matej21

@matej21 matej21 commented Aug 20, 2026

Copy link
Copy Markdown
Member

Consumer applications repeatedly bridge accessor types with as unknown as. This started as "make the __selected brand covariant so those casts go away".

That diagnosis is wrong

__selected is declared three times in handles/types.ts (HasManyRef:182, HasOneRefInterface:239, EntityRefInterface:317) as:

readonly __selected?: TSelected

A readonly optional property is an output position — it is already covariant. Widening a full accessor to a narrower selection compiles today; verified in all three intersection members. TBrand is unused in every body, and __schema?: TSchema & any collapses to any.

What actually fails is the other direction, for three distinct reasons, only one of which is the brand:

1. narrow → full is rejected twice over, and should be. By the brand, and independently by EntityFieldsRef / EntityFieldsAccessor, which are keyed on keyof TSelected and are therefore literally missing the properties. Loosening the brand cannot fix this — and must not:

// EntityHandle.ts:464
if (this.selection && !fieldMeta) {
	throw new UnfetchedFieldError(this.entityType, this.entityId, [fieldName])
}

Widening a narrow selection in place is a real runtime bug. The compiler is right to reject it, and some of the casts in consumer code are suppressing a crash, not a type-system artifact.

2. A free type parameter is unfixable by variance in principle. EntityFieldsRef<Website, Website>EntityFieldsRef<Website, TSelected> with TSelected unresolved cannot be accepted by any variance annotation, because the mapped type's key set is unknowable.

3. TEntityName is a second, independent cast generator (not in the original report). It is invariant through FieldRefMeta<TEntityName>.entityType + __entityName, so EntityAccessor<W,W,B,string> does not flow into EntityAccessor<W,W,B,'Website'>. Hooks produce literal names; createComponent's BuildEntityProps produces string.

Making the brand bivariant was considered and rejected: it is both a hole (it legalises the UnfetchedFieldError direction) and insufficient (the mapped types still reject).

__selected also turned out to be load-bearing for inference, not just checking — replacing it with unknown broke selection inference across ~30 sites. Anything that touches it ripples widely.

What this adds instead

Two additive types. No existing type is modified.

export type EntityRefLike<TEntity>      = EntityRefInterface<TEntity, unknown>
export type EntityAccessorLike<TEntity> = EntityRefLike<TEntity> & { readonly $data: unknown }

They erase TSelected and TEntityName, keep __entityType as the discriminator, and deliberately omit the field proxy. Use them in parameter positions that need entity identity plus the selection-independent API (id, $isNew, $persistedId, $errors, $on, $intercept, …).

EntityAccessorLike also admits HasOneAccessor — a has-one structurally satisfies EntityRefInterface. Intentional, and asserted.

What this does and does not buy

  • Consumers must opt in. A helper written EntityAccessor<Website> keeps failing. The zero-opt-in alternative — redefining EntityRef<T>'s default TSelected from TEntity to unknown — would strip field access from EntityRef<T> repo-wide; not attempted.
  • A receiver that reads fields must still declare the selection it needs. Erasure gives identity, not data. That is the point.
  • It does not help the $state === 'connected' narrowing case. HasOneAccessor is a plain intersection, not a discriminated union; no guard can narrow $entity. That needs HasOneAccessor restructured into a union keyed on $state — a separate, larger change.
  • No cast in this repo becomes removable. Every as unknown as touching ref/accessor types was checked; each has a different cause (class→structural-type impedance, collector proxies, role narrowing). The repeater's newEntity as unknown as EntityAccessor<TEntity> is a genuine instance, but its fix is to thread TSelected into RepeaterPreprocessCallback, because preprocess needs field access.

Soundness

15 tests in tests/unit/types/selectionErasure.test.ts. Still rejected, all asserted: unrelated entities both directions; pointer-only ref (no $data); HasManyRef and FieldRef; plain objects including { id: string; $data: unknown }; erasure is one-way (EntityRefLike<W>EntityRef<W>); no field proxy ('title' | 'slug' | '$fields'keyof EntityRefLike<Website>, 'id' ∈); the unsound widening stays illegal; and the brand is not vacuous.

Negative assertions use assertFalse<IsAssignable<S, T>>() with IsAssignable<S,T> = [S] extends [T] ? true : false (tuple-wrapped so unions do not distribute), matching the existing idiom in tests/typeSafety.test.ts. This is deliberately not @ts-expect-error, which is banned here and is in any case satisfied by any error on the line, including an unrelated typo.

Evidence the negatives bite, beyond "it compiles": weakening EntityRefLike to Partial<…> makes 5 negative assertions fail to compile.

One documented limit: with a free type parameter the conditional stays deferred (boolean), so "full accessor ↛ EntityRef<W, TSelected>" cannot be asserted false. There is a comment in the file rather than a faked assertion.

Gates

bun run typecheck exit 0 · bun test --path-ignore-patterns='**/tests/browser/**' 1752 pass / 0 fail / 150 files (baseline 1737 / 149).

Follow-up

Not re-exported from @contember/bindx-react. If a consumer only depends on the React package, these two names need adding to its root export — one line, left out of this PR's scope.

Consumers hand a selection-branded accessor to a helper typed for a
different selection and bridge the gap with `as unknown as`. The
diagnosis this was meant to fix - "the __selected brand is invariant" -
is wrong. `readonly __selected?: TSelected` is a readonly optional
property, i.e. an output position, and is already covariant: widening a
full accessor to a narrower selection compiles today.

What actually fails is the opposite direction, for three reasons, and
only one of them is the brand:

1. narrow -> full is rejected by the brand AND, independently, by
   EntityFieldsRef/EntityFieldsAccessor, which are keyed on
   `keyof TSelected` and so are missing the properties outright. It
   SHOULD be rejected: EntityHandle.fields throws UnfetchedFieldError
   for any field outside the selection, so widening in place is a real
   runtime bug. Loosening the brand would legalise it.
2. A free `TSelected` in a generic helper cannot be resolved at all - no
   variance annotation can fix an unknowable mapped-type key set.
3. TEntityName is invariant through FieldRefMeta.entityType, so a
   `string`-named accessor does not flow into a literal-named parameter.
   A second, independent cast generator.

So instead of changing an existing type, add two erased views:

    EntityRefLike<TEntity>      = EntityRefInterface<TEntity, unknown>
    EntityAccessorLike<TEntity> = EntityRefLike<TEntity> & { $data }

They erase TSelected and TEntityName, keep __entityType as the
discriminator, and deliberately omit the field proxy. Use them in
parameter positions that need entity identity and the
selection-independent API. A receiver that reads fields must still
declare the selection it needs - that cast was hiding a bug.

Nothing existing is modified. `__selected` also turned out to be
load-bearing for inference, not just checking: replacing it with
`unknown` breaks selection inference across ~30 sites.
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.

1 participant