diff --git a/packages/bindx-ui/src/select/multi-select-field.tsx b/packages/bindx-ui/src/select/multi-select-field.tsx index af54616..73a159e 100644 --- a/packages/bindx-ui/src/select/multi-select-field.tsx +++ b/packages/bindx-ui/src/select/multi-select-field.tsx @@ -41,8 +41,8 @@ import { SelectPopoverContent, } from './ui.js' -/** Extract the target entity type from a HasManyRef */ -type HasManyTarget = F extends HasManyRef ? TEntity : object +// `infer _S` (not `any`) is load-bearing — see relationTargetInference.test.ts. +type HasManyTarget = F extends HasManyRef ? TEntity : object /** Scalar field keys of an entity (for filter/sorting) */ type ScalarKeys = { [K in keyof T]: T[K] extends (object | object[] | null) ? never : K }[keyof T] & string diff --git a/packages/bindx-ui/src/select/select-field.tsx b/packages/bindx-ui/src/select/select-field.tsx index 9b87906..68f532e 100644 --- a/packages/bindx-ui/src/select/select-field.tsx +++ b/packages/bindx-ui/src/select/select-field.tsx @@ -38,8 +38,8 @@ import { SelectPopoverContent, } from './ui.js' -/** Extract the target entity type from a HasOneRef */ -type RelationTarget = F extends HasOneRef ? TEntity : object +// `infer _S` (not `any`) is load-bearing — see relationTargetInference.test.ts. +type RelationTarget = F extends HasOneRef ? TEntity : object /** Scalar field keys of an entity (for filter/sorting) */ type ScalarKeys = { [K in keyof T]: T[K] extends (object | object[] | null) ? never : K }[keyof T] & string diff --git a/packages/bindx/src/handles/index.ts b/packages/bindx/src/handles/index.ts index 22c6fd7..975156a 100644 --- a/packages/bindx/src/handles/index.ts +++ b/packages/bindx/src/handles/index.ts @@ -31,5 +31,8 @@ export { // Type extraction helpers type ExtractHasOneEntityName, type ExtractHasManyEntityName, + type ExtractHasOneEntity, + type ExtractHasManyEntity, + type ExtractEntityRefEntity, type ExtractRoleMap, } from './types.js' diff --git a/packages/bindx/src/handles/types.ts b/packages/bindx/src/handles/types.ts index 8259920..58ff3d2 100644 --- a/packages/bindx/src/handles/types.ts +++ b/packages/bindx/src/handles/types.ts @@ -251,6 +251,11 @@ export interface HasOneRefInterface< /** * HasOneRef = interface props + proxy field access returning Ref variants. + * + * Inference caveat: `T extends HasOneRef` does NOT reliably + * infer `E` when `T`'s `TSelected` is a narrow subset of `TEntity` — the + * mapped-type half of the intersection poisons inference. Use + * `ExtractHasOneEntity` (or `infer _S` instead of `any`) instead. */ export type HasOneRef< TEntity, @@ -316,6 +321,10 @@ export interface EntityRefInterface< /** * EntityRef = interface props + proxy field access returning Ref variants. + * + * Inference caveat: `T extends EntityRef` does NOT reliably + * infer `E` when `T`'s `TSelected` is a narrow subset of `TEntity`. Use + * `ExtractEntityRefEntity` (or `infer _S` instead of `any`) instead. */ export type EntityRef< TEntity, @@ -446,3 +455,16 @@ export type ExtractHasManyEntityName = : T extends HasManyRef ? TEntityName : never + +// Inference-safe target-entity extractors. Prefer these over hand-rolled +// `T extends HasOneRef ? E : ...` — see relationTargetInference.test.ts +// for why putting `any` in the TSelected slot poisons inference for these aliases. + +export type ExtractHasOneEntity = + T extends HasOneRefInterface ? TEntity : never + +export type ExtractHasManyEntity = + T extends HasManyRef ? TEntity : never + +export type ExtractEntityRefEntity = + T extends EntityRefInterface ? TEntity : never diff --git a/packages/bindx/src/index.ts b/packages/bindx/src/index.ts index 64112f7..8452075 100644 --- a/packages/bindx/src/index.ts +++ b/packages/bindx/src/index.ts @@ -70,6 +70,9 @@ export type { // Type extraction helpers ExtractHasOneEntityName, ExtractHasManyEntityName, + ExtractHasOneEntity, + ExtractHasManyEntity, + ExtractEntityRefEntity, ExtractRoleMap, } from './handles/index.js' diff --git a/tests/unit/handles/relationTargetInference.test.ts b/tests/unit/handles/relationTargetInference.test.ts new file mode 100644 index 0000000..b8d479d --- /dev/null +++ b/tests/unit/handles/relationTargetInference.test.ts @@ -0,0 +1,117 @@ +/** + * Type-level coverage for extracting the target entity from + * EntityRef / HasOneRef / HasManyRef via `infer`. + * + * Background: `EntityRef` and `HasOneRef` are type aliases + * defined as `XxxInterface & EntityFieldsRef`. The mapped + * half of the intersection is hostile to inference: when the target's + * `TSelected` is a strict subset of `TEntity`, matching against a candidate + * with `any` in the `TSelected` slot fails structurally and poisons + * `infer TEntity`, silently falling through to the conditional's fallback. + * + * Two safe shapes: + * 1. `T extends HasOneRef` — let TS pick `_S` itself. + * 2. `ExtractHasOneEntity` — routes through `HasOneRefInterface`, where + * the bug doesn't apply. + */ + +import { describe, test } from 'bun:test' +import type { + HasOneRef, + HasManyRef, + EntityRef, + ExtractHasOneEntity, + ExtractHasManyEntity, + ExtractEntityRefEntity, +} from '@contember/bindx' + +type Equal = + (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false + +function assertTrue(): void {} +function assertFalse(): void {} + +interface Family { + id: string + name: string + code: string + tariffs: { id: string; price: number }[] +} + +interface Tariff { + id: string + name: string + price: number +} + +type FamilyNarrow = { name: string } +type TariffNarrow = { id: string; name: string } + +declare const oneFull: HasOneRef +declare const oneNarrow: HasOneRef +declare const manyFull: HasManyRef +declare const manyNarrow: HasManyRef +declare const entityFull: EntityRef +declare const entityNarrow: EntityRef + +describe('HasOneRef target inference', () => { + test('broken: does not infer TEntity for narrow TSelected', () => { + type Extract = F extends HasOneRef ? T : object + assertTrue, Family>>() + assertFalse, Family>>() + }) + + test('safe: handles both wide and narrow TSelected', () => { + type Extract = F extends HasOneRef ? T : object + assertTrue, Family>>() + assertTrue, Family>>() + }) + + test('safe: ExtractHasOneEntity helper handles both wide and narrow TSelected', () => { + assertTrue, Family>>() + assertTrue, Family>>() + }) +}) + +describe('HasManyRef target inference', () => { + test('broken: (one arg) does not infer TEntity for narrow TSelected', () => { + type Extract = F extends HasManyRef ? T : object + assertFalse, Tariff>>() + }) + + test('safe: works (HasManyRef has no mapped-type intersection)', () => { + type Extract = F extends HasManyRef ? T : object + assertTrue, Tariff>>() + assertTrue, Tariff>>() + }) + + test('safe: works', () => { + type Extract = F extends HasManyRef ? T : object + assertTrue, Tariff>>() + assertTrue, Tariff>>() + }) + + test('safe: ExtractHasManyEntity helper works', () => { + assertTrue, Tariff>>() + assertTrue, Tariff>>() + }) +}) + +describe('EntityRef target inference', () => { + test('broken: does not infer TEntity for narrow TSelected', () => { + type Extract = F extends EntityRef ? T : object + assertTrue, Family>>() + assertFalse, Family>>() + }) + + test('safe: handles both wide and narrow TSelected', () => { + type Extract = F extends EntityRef ? T : object + assertTrue, Family>>() + assertTrue, Family>>() + }) + + test('safe: ExtractEntityRefEntity helper handles both wide and narrow TSelected', () => { + assertTrue, Family>>() + assertTrue, Family>>() + }) +})