Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions lib/services/schema-object-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,22 @@ export class SchemaObjectFactory {
| ParameterObject
| (SchemaObject & { selfRequired?: boolean }) {
const typeRef = nestedArrayType || metadata.type;
if (this.isConstEnumObject(typeRef as Record<string, any>)) {
const enumValues = getEnumValues(typeRef as Record<string, any>);
const enumType = getEnumType(enumValues);
const syntheticMetadata = {
...metadata,
type: enumType,
enum: enumValues
} as SchemaObjectMetadata;
return this.createSchemaMetadata(
key,
syntheticMetadata,
schemas,
pendingSchemaRefs,
enumType
);
}
if (this.isObjectLiteral(typeRef as Record<string, any>)) {
const schemaFromObjectLiteral = this.createFromObjectLiteral(
key,
Expand Down Expand Up @@ -817,6 +833,28 @@ export class SchemaObjectFactory {
return type === BigInt;
}

/**
* Determines whether an object is a const-enum-like object (e.g., created with `as const`).
* Such objects have all values as primitive strings or numbers and no function values.
* This pattern is used when TypeScript namespaces merge a const object and a type alias:
* export const MyEnum = { A: 'a', B: 'b' } as const;
* export type MyEnum = (typeof MyEnum)[keyof typeof MyEnum];
* With SWC, `Reflect.getMetadata('design:type', ...)` may resolve to the const object
* itself rather than `Object`, causing swagger to misinterpret it as an object literal schema.
*/
private isConstEnumObject(obj: Record<string, any>): boolean {
if (typeof obj !== 'object' || !obj || Array.isArray(obj)) {
return false;
}
const values = Object.values(obj);
if (values.length === 0) {
return false;
}
return values.every(
(value) => typeof value === 'string' || typeof value === 'number'
);
}

private extractPropertyModifiers(
metadata: SchemaObjectMetadata
): [Partial<SchemaObjectMetadata>, string[]] {
Expand Down
84 changes: 84 additions & 0 deletions test/services/schema-object-factory.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ApiExtension, ApiProperty, ApiSchema } from '../../lib/decorators';
import { DECORATORS } from '../../lib/constants';
import { Logger } from '@nestjs/common';
import {
BaseParameterObject,
Expand Down Expand Up @@ -818,5 +819,88 @@ describe('SchemaObjectFactory', () => {
expect(result.type).toBe('array');
});
});

describe('SWC const-enum compatibility (issue #3326)', () => {
// Simulate the `as const` pattern that SWC resolves as the const object for design:type:
// export const MyEnum = { FOO: 'FOO', BAR: 'BAR' } as const;
// export type MyEnum = (typeof MyEnum)[keyof typeof MyEnum];
const MyStringEnum = { FOO: 'FOO', BAR: 'BAR' } as const;
const MyNumericEnum = { ONE: 1, TWO: 2 } as const;

it('should handle a const-enum object as design:type without throwing circular dependency error', () => {
// Simulate what SWC emits: design:type is the const object itself
class SwcDto {
someEnum: any;
}
Reflect.defineMetadata(
DECORATORS.API_MODEL_PROPERTIES,
{ type: MyStringEnum, required: false },
SwcDto.prototype,
'someEnum'
);
Reflect.defineMetadata(
DECORATORS.API_MODEL_PROPERTIES_ARRAY,
[':someEnum'],
SwcDto.prototype
);

const schemas: Record<string, any> = {};
expect(() =>
schemaObjectFactory.exploreModelSchema(SwcDto as any, schemas)
).not.toThrow();
});

it('should produce an enum schema when design:type is a string const-enum object (SWC behavior)', () => {
class SwcStringEnumDto {
status: any;
}
Reflect.defineMetadata(
DECORATORS.API_MODEL_PROPERTIES,
{ type: MyStringEnum, required: true },
SwcStringEnumDto.prototype,
'status'
);
Reflect.defineMetadata(
DECORATORS.API_MODEL_PROPERTIES_ARRAY,
[':status'],
SwcStringEnumDto.prototype
);

const schemas: Record<string, any> = {};
schemaObjectFactory.exploreModelSchema(SwcStringEnumDto as any, schemas);

expect(schemas['SwcStringEnumDto']).toBeDefined();
const statusProp = schemas['SwcStringEnumDto'].properties['status'];
expect(statusProp).toBeDefined();
// Should be treated as an enum, not throw a circular dependency error
expect(statusProp.type ?? statusProp.allOf).toBeDefined();
});

it('should produce an enum schema when design:type is a numeric const-enum object (SWC behavior)', () => {
class SwcNumericEnumDto {
rank: any;
}
Reflect.defineMetadata(
DECORATORS.API_MODEL_PROPERTIES,
{ type: MyNumericEnum, required: true },
SwcNumericEnumDto.prototype,
'rank'
);
Reflect.defineMetadata(
DECORATORS.API_MODEL_PROPERTIES_ARRAY,
[':rank'],
SwcNumericEnumDto.prototype
);

const schemas: Record<string, any> = {};
expect(() =>
schemaObjectFactory.exploreModelSchema(SwcNumericEnumDto as any, schemas)
).not.toThrow();

expect(schemas['SwcNumericEnumDto']).toBeDefined();
const rankProp = schemas['SwcNumericEnumDto'].properties['rank'];
expect(rankProp).toBeDefined();
});
});
});