@@ -64,9 +64,9 @@ function isObjectField(field: MetaData): boolean {
6464
6565/**
6666 * The union-alias type name for a `field.enum` with effective `@values`, or undefined when the
67- * field is not a value-constrained enum. Reuses `enumUnionAliasName` — the SAME naming the payload
68- * emitter (`payload-codegen.ts`) types the field as — so the cast target resolves to the exact
69- * alias exported from `payloads.ts` . `ownerName` is the owning value-object's interface name.
67+ * field is not a value-constrained enum. Reuses `enumUnionAliasName` — the SAME naming the entity
68+ * inferred-types emitter types the field as — so the cast target resolves to the exact alias
69+ * exported from the owning VO's entity module . `ownerName` is the owning value-object's interface name.
7070 */
7171function enumAlias ( field : MetaData , ownerName : string ) : string | undefined {
7272 if ( field . subType !== FIELD_SUBTYPE_ENUM ) return undefined ;
@@ -76,11 +76,12 @@ function enumAlias(field: MetaData, ownerName: string): string | undefined {
7676}
7777
7878/**
79- * True iff the field is required IN THE STRICT PAYLOAD TYPE. This MUST match
80- * payload-codegen.ts's `isFieldRequired` predicate EXACTLY (boolean `true` only) — the payload
81- * interface decides `T` vs `T | null` by that predicate, and the mapper's optionality assumption
82- * (`m.f!` vs `m.f ?? null`) has to agree with the type it is constructing. A `@required:"true"`
83- * string field is therefore `T | null` in the payload AND optional here (no skew).
79+ * True iff the field is required IN THE STRICT PAYLOAD TYPE. The strict payload IS the VO's own
80+ * generated entity-module interface (`renderValueObjectInterface`), which types a required field
81+ * `f: T` and an optional one `f?: T` (i.e. `T | undefined` — NOT `T | null`). So the mapper's
82+ * optionality assumption (`m.f!` vs `m.f ?? undefined`) has to agree with THAT interface, and an
83+ * absent optional maps to `undefined`, never `null`. This predicate matches the interface's
84+ * required test (boolean `true` only) so the two never skew.
8485 */
8586function isFieldRequired ( field : MetaData ) : boolean {
8687 return field . ownAttr ( FIELD_ATTR_REQUIRED ) === true ;
@@ -93,8 +94,10 @@ function mapperName(vo: MetaData): string {
9394
9495/**
9596 * The mapper-body initializer expression for one field, reading mirror member `m.<name>` and
96- * mapping it onto the strict payload's exact optionality (required → `m.f!`; optional → `m.f ?? null`).
97- * Nested single/array objects recurse into their toStrict<Type> mapper, guarding null when optional.
97+ * mapping it onto the strict payload's exact optionality (required → `m.f!`; optional → `m.f ?? undefined`).
98+ * The strict payload is the VO's generated entity-module interface, whose optional fields are
99+ * `f?: T` (= `T | undefined`, never `T | null`), so an absent optional maps to `undefined`.
100+ * Nested single/array objects recurse into their toStrict<Type> mapper, guarding when optional.
98101 */
99102function strictArg ( field : MetaData , root : MetaData , ownerName : string ) : string {
100103 const name = field . name ;
@@ -104,25 +107,25 @@ function strictArg(field: MetaData, root: MetaData, ownerName: string): string {
104107 const target = refVo ( field , root ) ;
105108 if ( target === undefined ) {
106109 // Unresolved @objectRef — the payload type would be `unknown`; pass through as-is.
107- return required ? `m.${ name } !` : `m.${ name } ?? null ` ;
110+ return required ? `m.${ name } !` : `m.${ name } ?? undefined ` ;
108111 }
109112 const fn = mapperName ( target ) ;
110113 if ( isArray ( field ) ) {
111114 // Required array-of-objects: each element mapped; element nulls dropped at the type level
112115 // via the non-null assertion (extract never yields null elements for a present array).
113116 if ( required ) return `m.${ name } !.map((e) => ${ fn } (e!))` ;
114- return `m.${ name } ? m.${ name } !.map((e) => ${ fn } (e!)) : null ` ;
117+ return `m.${ name } ? m.${ name } !.map((e) => ${ fn } (e!)) : undefined ` ;
115118 }
116119 // Single nested object.
117120 if ( required ) return `${ fn } (m.${ name } !)` ;
118- return `m.${ name } ? ${ fn } (m.${ name } ) : null ` ;
121+ return `m.${ name } ? ${ fn } (m.${ name } ) : undefined ` ;
119122 }
120123
121124 // Scalar ARRAY (e.g. `field.string` with isArray): the mirror types it `(T | null)[] | null`
122- // but the strict payload types it `T[]` (required) / `T[] | null ` (optional). A bare `m.f!`
125+ // but the strict payload types it `T[]` (required) / `T[]? ` (optional). A bare `m.f!`
123126 // would leave the element type `T | null`, a `tsc --strict` TS2322 error. Filter out null
124127 // elements so the element type narrows to non-null (consistent with the lost-element DROP policy
125- // already used for required arrays-of-objects above).
128+ // already used for required arrays-of-objects above). An absent optional array maps to `undefined`.
126129 //
127130 // ENUM arrays: the mirror element is a plain `string`, but the strict payload types it as the
128131 // closed `<Alias>[]` union. The null-filter alone narrows to `string[]`, not `<Alias>[]` — a
@@ -136,23 +139,23 @@ function strictArg(field: MetaData, root: MetaData, ownerName: string): string {
136139 return alias !== undefined ? `(${ filtered } ) as ${ alias } []` : filtered ;
137140 }
138141 const filtered = `m.${ name } .filter((x): x is NonNullable<typeof x> => x != null)` ;
139- const guarded = `m.${ name } == null ? null : ${ filtered } ` ;
142+ const guarded = `m.${ name } == null ? undefined : ${ filtered } ` ;
140143 return alias !== undefined
141- ? `m.${ name } == null ? null : (${ filtered } ) as ${ alias } []`
144+ ? `m.${ name } == null ? undefined : (${ filtered } ) as ${ alias } []`
142145 : guarded ;
143146 }
144147
145148 // Scalar / enum (single): the strict payload's optionality decides the shape.
146- // Required → non-null assertion; optional → `?? null ` (matches the payload's `f?: T | null `).
149+ // Required → non-null assertion; optional → `?? undefined ` (matches the entity-module `f?: T`).
147150 //
148151 // ENUM scalar: the mirror member is a plain `string`, but the strict payload types it as the
149152 // closed `<Alias>` union — assigning `string` into `<Alias>` is a `tsc --strict` TS2322 error.
150153 // So the value is CAST to `<Alias>`. Sound for the same reason as enum arrays above: the engine
151154 // already validated membership (or extract throws on a lost required field).
152155 if ( alias !== undefined ) {
153- return required ? `m.${ name } ! as ${ alias } ` : `(m.${ name } ?? null ) as ${ alias } | null ` ;
156+ return required ? `m.${ name } ! as ${ alias } ` : `(m.${ name } ?? undefined ) as ${ alias } | undefined ` ;
154157 }
155- return required ? `m.${ name } !` : `m.${ name } ?? null ` ;
158+ return required ? `m.${ name } !` : `m.${ name } ?? undefined ` ;
156159}
157160
158161/**
@@ -202,32 +205,52 @@ function emitMapper(
202205}
203206
204207/**
205- * Collect the strict payload-interface names reachable from `vo` (for the type-only import),
206- * PLUS every enum union-alias reachable from those VOs. Both are exported from `payloads.ts`
207- * (the alias is hoisted above the interface there), so the extractor's `as <Alias>` casts need
208- * the alias names imported alongside the interface names. Deduped, in discovery order.
208+ * One payload-type import group: the strict types (VO interface + its own enum
209+ * union-aliases) imported from a single VO entity module.
209210 */
210- function reachablePayloadTypes ( vo : MetaData , root : MetaData ) : string [ ] {
211- const order : string [ ] = [ ] ;
212- const seen = new Set < string > ( ) ;
211+ interface PayloadImportGroup {
212+ /** The VO whose entity module exports these types (`./<module>.js`). */
213+ module : string ;
214+ /** The strict type names exported by that module, in discovery order. */
215+ types : string [ ] ;
216+ }
217+
218+ /**
219+ * Group the strict payload types reachable from `vo` BY THE ENTITY MODULE THAT EXPORTS THEM.
220+ *
221+ * Each value-object gets its own generated entity module (`entityFile()` emits `<VO>.ts` exporting
222+ * `export interface <VO>`), and `renderEnumTypeAliases` hoists every `field.enum` union-alias INTO
223+ * the OWNING VO's module (`export type <Owner><Field> = ...` co-located with the interface). So a
224+ * VO's interface AND the aliases for its own enum fields are imported from `./<VO>.js` — NOT from a
225+ * single `payloads.ts` (which no generator emits). Deduped, in discovery order, one group per VO.
226+ */
227+ function reachablePayloadGroups ( vo : MetaData , root : MetaData ) : PayloadImportGroup [ ] {
228+ const groups : PayloadImportGroup [ ] = [ ] ;
229+ const seenVo = new Set < string > ( ) ;
230+ const seenAlias = new Set < string > ( ) ;
213231 const visit = ( cur : MetaData ) => {
214- if ( seen . has ( cur . name ) ) return ;
215- seen . add ( cur . name ) ;
216- order . push ( cur . name ) ;
232+ if ( seenVo . has ( cur . name ) ) return ;
233+ seenVo . add ( cur . name ) ;
234+ // The VO interface + its OWN enum aliases share the VO's entity module.
235+ const types : string [ ] = [ cur . name ] ;
217236 for ( const f of fields ( cur ) ) {
218237 const alias = enumAlias ( f , cur . name ) ;
219- if ( alias !== undefined && ! seen . has ( alias ) ) {
220- seen . add ( alias ) ;
221- order . push ( alias ) ;
238+ if ( alias !== undefined && ! seenAlias . has ( alias ) ) {
239+ seenAlias . add ( alias ) ;
240+ types . push ( alias ) ;
222241 }
242+ }
243+ groups . push ( { module : cur . name , types } ) ;
244+ // Recurse into nested object refs (their interfaces live in their own modules).
245+ for ( const f of fields ( cur ) ) {
223246 if ( isObjectField ( f ) ) {
224247 const target = refVo ( f , root ) ;
225248 if ( target !== undefined ) visit ( target ) ;
226249 }
227250 }
228251 } ;
229252 visit ( vo ) ;
230- return order ;
253+ return groups ;
231254}
232255
233256/** Collect the mirror-interface names reachable from `vo` (root mirror + nested VO mirrors). */
@@ -286,10 +309,16 @@ export function renderExtractor(root: MetaData, templateName: string): string {
286309 const extractName = `extract${ templateName } ` ;
287310 const rootMapper = mapperName ( vo ) ;
288311
289- const payloadTypes = reachablePayloadTypes ( vo , root ) ;
312+ const payloadGroups = reachablePayloadGroups ( vo , root ) ;
290313 const mirrorTypes = reachableMirrorTypes ( vo , root , rootMirror ) ;
291314 const mappers = emitMappers ( vo , root , rootMirror ) ;
292315
316+ // One type-only import per VO entity module (the VO interface + its own enum
317+ // union-aliases co-located there). NOT a single non-existent `./payloads.js`.
318+ const payloadImports = payloadGroups
319+ . map ( ( g ) => `import type { ${ g . types . join ( ", " ) } } from "./${ g . module } .js";` )
320+ . join ( "\n" ) ;
321+
293322 const lostMsg =
294323 `${ extractName } : lost required field(s): ` ;
295324
@@ -301,7 +330,7 @@ export function renderExtractor(root: MetaData, templateName: string): string {
301330 `// mapping the all-nullable mirror onto the strict payload. No registry / binding / factory.\n` +
302331 `\n` +
303332 `import {\n ${ extractLenientWithName } ,\n type ${ mirrorTypes . join ( ",\n type " ) } ,\n} from "./${ templateName } .output.js";\n` +
304- `import type { ${ payloadTypes . join ( ", " ) } } from "./payloads.js"; \n` +
333+ `${ payloadImports } \n` +
305334 `import type { MetaRoot } from "@metaobjectsdev/metadata";\n` +
306335 `import type { ExtractionResult } from "@metaobjectsdev/render";\n` +
307336 `\n` +
0 commit comments