@@ -73,36 +73,36 @@ export class ModelClassVisitor extends AbstractFileVisitor {
7373
7474 const propertyNodeVisitorFactory =
7575 ( metadata : ClassMetadata ) =>
76- ( node : ts . Node ) : ts . Node => {
77- const visit = ( ) => {
78- if ( ts . isPropertyDeclaration ( node ) ) {
79- this . visitPropertyNodeDeclaration (
80- node ,
81- ctx ,
82- typeChecker ,
83- options ,
84- sourceFile ,
85- metadata
86- ) ;
87- } else if (
88- options . parameterProperties &&
89- ts . isConstructorDeclaration ( node )
90- ) {
91- this . visitConstructorDeclarationNode (
92- node ,
93- typeChecker ,
94- options ,
95- sourceFile ,
96- metadata
97- ) ;
76+ ( node : ts . Node ) : ts . Node => {
77+ const visit = ( ) => {
78+ if ( ts . isPropertyDeclaration ( node ) ) {
79+ this . visitPropertyNodeDeclaration (
80+ node ,
81+ ctx ,
82+ typeChecker ,
83+ options ,
84+ sourceFile ,
85+ metadata
86+ ) ;
87+ } else if (
88+ options . parameterProperties &&
89+ ts . isConstructorDeclaration ( node )
90+ ) {
91+ this . visitConstructorDeclarationNode (
92+ node ,
93+ typeChecker ,
94+ options ,
95+ sourceFile ,
96+ metadata
97+ ) ;
98+ }
99+ return node ;
100+ } ;
101+ const visitedNode = visit ( ) ;
102+ if ( ! options . readonly ) {
103+ return visitedNode ;
98104 }
99- return node ;
100105 } ;
101- const visitedNode = visit ( ) ;
102- if ( ! options . readonly ) {
103- return visitedNode ;
104- }
105- } ;
106106
107107 const visitClassNode = ( node : ts . Node ) : ts . Node => {
108108 if ( ts . isClassDeclaration ( node ) ) {
@@ -347,10 +347,10 @@ export class ModelClassVisitor extends AbstractFileVisitor {
347347 const properties = [
348348 ...existingProperties ,
349349 ! hasPropertyKey ( 'required' , existingProperties ) &&
350- factory . createPropertyAssignment (
351- 'required' ,
352- createBooleanLiteral ( factory , isRequired )
353- ) ,
350+ factory . createPropertyAssignment (
351+ 'required' ,
352+ createBooleanLiteral ( factory , isRequired )
353+ ) ,
354354 ...this . createTypePropertyAssignments (
355355 factory ,
356356 node . type ,
@@ -397,6 +397,12 @@ export class ModelClassVisitor extends AbstractFileVisitor {
397397 * Returns an array with 0..2 "ts.PropertyAssignment"s.
398398 * The first one is the "type" property assignment, the second one is the "nullable" property assignment.
399399 * When type cannot be determined, an empty array is returned.
400+ *
401+ * Special handling:
402+ * - For unions like `Enum | null` or `Enum | null | undefined` (and their array forms),
403+ * we DO NOT emit a lazy `type: () => ...`. The enum metadata is emitted elsewhere
404+ * by createEnumPropertyAssignment(). Here we only add `nullable: true` when needed.
405+ * This avoids circular dependency errors for optional+nullable enum properties.
400406 */
401407 private createTypePropertyAssignments (
402408 factory : ts . NodeFactory ,
@@ -412,6 +418,7 @@ export class ModelClassVisitor extends AbstractFileVisitor {
412418 }
413419
414420 if ( node ) {
421+ // Array of inline object literal
415422 if ( ts . isArrayTypeNode ( node ) && ts . isTypeLiteralNode ( node . elementType ) ) {
416423 const initializer = this . createInitializerForArrayLiteralTypeNode (
417424 node ,
@@ -422,7 +429,10 @@ export class ModelClassVisitor extends AbstractFileVisitor {
422429 options
423430 ) ;
424431 return [ factory . createPropertyAssignment ( key , initializer ) ] ;
425- } else if ( ts . isTypeLiteralNode ( node ) ) {
432+ }
433+
434+ // Inline object literal
435+ if ( ts . isTypeLiteralNode ( node ) ) {
426436 const initializer = this . createInitializerForTypeLiteralNode (
427437 node ,
428438 factory ,
@@ -432,17 +442,59 @@ export class ModelClassVisitor extends AbstractFileVisitor {
432442 options
433443 ) ;
434444 return [ factory . createPropertyAssignment ( key , initializer ) ] ;
435- } else if ( ts . isUnionTypeNode ( node ) ) {
445+ }
446+
447+ // Union types (where nullable/undefined might be part of the union)
448+ if ( ts . isUnionTypeNode ( node ) ) {
436449 const { nullableType, isNullable } = this . isNullableUnion ( node ) ;
437- const remainingTypes = node . types . filter (
438- ( item ) => item !== nullableType
439- ) ;
450+ const remainingTypes = node . types . filter ( ( t ) => t !== nullableType ) ;
440451
441- // TODO: When we have more than 1 type left , we could use "oneOf"
452+ // If exactly one non-nullish type remains , we can reason about it.
442453 if ( remainingTypes . length === 1 ) {
454+ const nonNullishNode = remainingTypes [ 0 ] ;
455+
456+ // Detect if the non-nullish side is (or resolves to) an enum (including array of enum).
457+ // If yes, DO NOT emit a lazy `type`; only emit { nullable: true } if needed.
458+ const resolved = typeChecker . getTypeAtLocation ( nonNullishNode ) ;
459+ let candidateType = resolved ;
460+
461+ // If it's an array (e.g., Status[] | null), drill into the element type
462+ const arrayTuple = extractTypeArgumentIfArray ( candidateType ) ;
463+ if ( arrayTuple ) {
464+ candidateType = arrayTuple . type ;
465+ }
466+
467+ let isEnumType = false ;
468+ if ( candidateType ) {
469+ if ( isEnum ( candidateType ) ) {
470+ isEnumType = true ;
471+ } else {
472+ // Handle auto-generated enum unions (string literal unions that represent an enum)
473+ const maybeEnum = isAutoGeneratedEnumUnion ( candidateType , typeChecker ) ;
474+ if ( maybeEnum ) {
475+ isEnumType = true ;
476+ }
477+ }
478+ }
479+
480+ if ( isEnumType ) {
481+ // For enums, skip returning a "type" property (avoid lazy resolver/circular deps).
482+ // Only append { nullable: true } if the union contained `null`.
483+ // The enum metadata will be added by createEnumPropertyAssignment().
484+ return isNullable
485+ ? [
486+ factory . createPropertyAssignment (
487+ 'nullable' ,
488+ createBooleanLiteral ( factory , true )
489+ )
490+ ]
491+ : [ ] ;
492+ }
493+
494+ // Not an enum: keep existing behavior (recurse into the remaining node)
443495 const propertyAssignments = this . createTypePropertyAssignments (
444496 factory ,
445- remainingTypes [ 0 ] ,
497+ nonNullishNode ,
446498 typeChecker ,
447499 existingProperties ,
448500 hostFilename ,
@@ -459,10 +511,13 @@ export class ModelClassVisitor extends AbstractFileVisitor {
459511 )
460512 ] ;
461513 }
514+ // >1 remaining non-nullish types: fall through (no special handling here).
515+ // (Potential future: oneOf support)
462516 }
463517 }
464518
465- const type = typeChecker . getTypeAtLocation ( node ) ;
519+ // Fallback: emit a lazy `type: () => Identifier` when we can resolve a referenceable type name
520+ const type = typeChecker . getTypeAtLocation ( node as any ) ;
466521 if ( ! type ) {
467522 return [ ] ;
468523 }
@@ -492,6 +547,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
492547 return [ factory . createPropertyAssignment ( key , initializer ) ] ;
493548 }
494549
550+
551+
495552 createInitializerForArrayLiteralTypeNode (
496553 node : ts . ArrayTypeNode ,
497554 factory : ts . NodeFactory ,
@@ -592,10 +649,43 @@ export class ModelClassVisitor extends AbstractFileVisitor {
592649 if ( hasPropertyKey ( key , existingProperties ) ) {
593650 return undefined ;
594651 }
595- let type = typeChecker . getTypeAtLocation ( node ) ;
652+ // Prefer using the explicit TypeNode when available to get the declared type
653+ // (this helps in cases like optional/nullable unions where the TypeNode reflects the union)
654+ let type : ts . Type | undefined ;
655+ try {
656+ if ( ( node as any ) . type ) {
657+ type = typeChecker . getTypeFromTypeNode ( ( node as any ) . type as ts . TypeNode ) ;
658+ }
659+ } catch ( e ) {
660+ // fallthrough to getTypeAtLocation
661+ }
662+ if ( ! type ) {
663+ type = typeChecker . getTypeAtLocation ( node as any ) ;
664+ }
596665 if ( ! type ) {
597666 return undefined ;
598667 }
668+
669+ if ( ( type . flags & ts . TypeFlags . Union ) !== 0 ) {
670+ const union = type as ts . UnionOrIntersectionType ;
671+ const nonNullish = union . types . filter (
672+ ( t ) => ( t . flags & ( ts . TypeFlags . Null | ts . TypeFlags . Undefined ) ) === 0
673+ ) ;
674+ if ( nonNullish . length === 1 ) {
675+ type = nonNullish [ 0 ] ;
676+ }
677+ }
678+
679+ // Also handle cases where TypeScript emits an auto-generated union like `T | undefined`
680+ // (strict mode). In that case, pick the non-undefined member so enum detection works.
681+ if ( isAutoGeneratedTypeUnion ( type ) ) {
682+ const types = ( type as ts . UnionOrIntersectionType ) . types ;
683+ const nonUndefined = types . find ( ( t : any ) => t . intrinsicName !== 'undefined' ) ;
684+ if ( nonUndefined ) {
685+ type = nonUndefined as ts . Type ;
686+ }
687+ }
688+
599689 if ( isAutoGeneratedTypeUnion ( type ) ) {
600690 const types = ( type as ts . UnionOrIntersectionType ) . types ;
601691 type = types [ types . length - 1 ] ;
0 commit comments