Skip to content

Commit c1a062f

Browse files
Merge pull request #3544 from AliRaZa1121/fix/3519-nullable-optional-enum
fix(plugin): correct enum+nullable handling for `Enum | null` (and ar…
2 parents c8352cb + fef1c59 commit c1a062f

File tree

3 files changed

+155
-60
lines changed

3 files changed

+155
-60
lines changed

lib/decorators/helpers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
import { METHOD_METADATA } from '@nestjs/common/constants';
2+
import { isConstructor } from '@nestjs/common/utils/shared.utils';
13
import { isArray, isUndefined, negate, pickBy } from 'lodash';
24
import { DECORATORS } from '../constants';
35
import { METADATA_FACTORY_NAME } from '../plugin/plugin-constants';
4-
import { METHOD_METADATA } from '@nestjs/common/constants';
5-
import { isConstructor } from '@nestjs/common/utils/shared.utils';
66

77
export function createMethodDecorator<T = any>(
88
metakey: string,

lib/plugin/visitors/model-class.visitor.ts

Lines changed: 131 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -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];

test/plugin/fixtures/nullable.dto.ts

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,24 @@ enum Status {
99
}
1010
1111
export class NullableDto {
12-
@ApiProperty()
13-
stringValue: string | null;
14-
@ApiProperty()
15-
stringArr: string[] | null;
16-
@ApiProperty()
17-
optionalString?: string;
18-
@ApiProperty()
19-
undefinedString: string | undefined;
20-
@ApiProperty()
21-
nullableEnumValue: OneValueEnum | null;
22-
@ApiProperty()
23-
optionalEnumValue?: OneValueEnum;
24-
@ApiProperty()
25-
undefinedEnumValue: OneValueEnum | undefined;
26-
@ApiProperty()
27-
enumValue: Status | null;
12+
@ApiProperty()
13+
stringValue: string | null;
14+
@ApiProperty()
15+
stringArr: string[] | null;
16+
@ApiProperty()
17+
optionalString?: string;
18+
@ApiProperty()
19+
undefinedString: string | undefined;
20+
@ApiProperty()
21+
nullableEnumValue: OneValueEnum | null;
22+
@ApiProperty()
23+
optionalEnumValue?: OneValueEnum;
24+
@ApiProperty()
25+
undefinedEnumValue: OneValueEnum | undefined;
26+
@ApiProperty()
27+
enumValue: Status | null;
28+
@ApiProperty()
29+
optionalNullableEnumValue?: Status | null;
2830
}
2931
`;
3032

@@ -40,7 +42,7 @@ var Status;
4042
})(Status || (Status = {}));
4143
export class NullableDto {
4244
static _OPENAPI_METADATA_FACTORY() {
43-
return { stringValue: { required: true, type: () => String, nullable: true }, stringArr: { required: true, type: () => [String], nullable: true }, optionalString: { required: false, type: () => String }, undefinedString: { required: true, type: () => String }, nullableEnumValue: { required: true, nullable: true, enum: OneValueEnum }, optionalEnumValue: { required: false, enum: OneValueEnum }, undefinedEnumValue: { required: true, enum: OneValueEnum }, enumValue: { required: true, nullable: true, enum: Status } };
45+
return { stringValue: { required: true, type: () => String, nullable: true }, stringArr: { required: true, type: () => [String], nullable: true }, optionalString: { required: false, type: () => String }, undefinedString: { required: true, type: () => String }, nullableEnumValue: { required: true, nullable: true, enum: OneValueEnum }, optionalEnumValue: { required: false, enum: OneValueEnum }, undefinedEnumValue: { required: true, enum: OneValueEnum }, enumValue: { required: true, nullable: true, enum: Status }, optionalNullableEnumValue: { required: false, nullable: true, enum: Status } };
4446
}
4547
}
4648
__decorate([
@@ -67,4 +69,7 @@ __decorate([
6769
__decorate([
6870
ApiProperty()
6971
], NullableDto.prototype, "enumValue", void 0);
72+
__decorate([
73+
ApiProperty()
74+
], NullableDto.prototype, "optionalNullableEnumValue", void 0);
7075
`;

0 commit comments

Comments
 (0)