-
Notifications
You must be signed in to change notification settings - Fork 204
Description
One of the key responsibilities of a Response DTO is to clearly and explicitly define which fields are exposed to the client. This ensures we avoid unintentionally leaking internal, sensitive, or irrelevant data.
However, the current approach, especially in cases involving nested OmitType
, multiple @Exclude()
decorators and inherited DTOs, can be a bit difficult to reason about and debug.
Let’s walk through an example to illustrate the concern.
GET /api/v1/system/role/list
This endpoint uses the RoleShortResponseDto
:
export class RoleShortResponseDto extends OmitType(RoleListResponseDto, [
'permissions',
'createdAt',
'isActive',
'updatedAt',
] as const) {
@ApiHideProperty()
@Exclude()
permissions: number;
@ApiHideProperty()
@Exclude()
isActive: boolean;
@ApiHideProperty()
@Exclude()
createdAt: Date;
@ApiHideProperty()
@Exclude()
updatedAt: Date;
}
Which itself inherits from RoleListResponseDto
:
export class RoleListResponseDto extends OmitType(RoleGetResponseDto, [
'permissions',
'description',
] as const) {
@ApiHideProperty()
@Exclude()
description?: string;
@ApiProperty({
description: 'count of permissions',
required: true,
})
@Transform(({ value }) => value.length)
permissions: number;
}
So to understand the final structure of the response, we need to:
- Look at the base class,
- Subtract what’s omitted,
- Subtract what’s excluded,
- Mentally reassemble what’s left.
This makes it harder for developers to quickly determine what’s actually returned.
Actual JSON Response
{
"data": [
{
"_id": "18a44bab-6e22-492d-ab5c-9ac316bbee3d",
"deleted": false,
"name": "superadmin",
"type": "SUPER_ADMIN"
},
{
"_id": "b37fb19b-3b18-4587-b12b-d6db72e1287a",
"deleted": false,
"name": "admin",
"type": "ADMIN"
},
{
"_id": "4f867313-5b03-4172-a799-8c12f468a2c3",
"deleted": false,
"name": "individual",
"type": "USER"
},
{
"_id": "87a17ef3-ef71-46fa-9f82-f3b75b744b22",
"deleted": false,
"name": "premium",
"type": "USER"
},
{
"_id": "0c7e4104-8ac5-4010-91c7-da33c0958af4",
"deleted": false,
"name": "business",
"type": "USER"
}
]
}
What am i proposing ?
I don’t have a definitive solution yet, but I’d like to propose a more explicit and developer-friendly approach: essentially flipping the logic.
Instead of “omitting” and “excluding” fields, we explicitly expose only the fields we intend to return. This gives clearer control and helps avoid surprises.
I’m not entirely sure how this would impact the current Swagger documentation setup.
I know the existing DTO structure is designed with Swagger decorators in mind, so any change should be evaluated carefully to ensure the docs remain accurate and helpful.
The only proposal which i though make sense, more or less:
- Use
@Expose()
on all fields meant to be returned - Enable
excludeExtraneousValues
in the class transformer
mapGet(role: RoleDoc | RoleEntity, options?: ClassTransformOptions): RoleGetResponseDto {
return plainToInstance(
RoleGetResponseDto,
role instanceof Document ? role.toObject() : role,
options
);
}
And call the method from the controller with excludeExtraneousValues
(This will ONLY Render property annotated with @Exposed()
)
const mapped = this.roleService.mapList(auctions, {
excludeExtraneousValues: true,
});
The Response DTOs will look like:
export class RoleShortResponseDto extends OmitType(RoleGetResponseDto, [
'deleted',
'name',
'type',
'_id',
] as const) {
}
export class RoleGetResponseDto extends DatabaseUUIDDto {
@ApiProperty({
description: 'Name of role',
example: faker.person.jobTitle(),
required: true,
})
@Exposed()
name: string;
@ApiProperty({
description: 'Description of role',
example: faker.lorem.sentence(),
required: false,
maxLength: 500,
})
@Exposed() // even if this is exposed, its not inherited
description?: string;
@ApiProperty({
description: 'Active flag of role',
example: true,
required: true,
})
@Exposed() // even if this is exposed, its not inherited
isActive: boolean;
@ApiProperty({
description: 'Representative for role type',
example: ENUM_POLICY_ROLE_TYPE.ADMIN,
required: true,
enum: ENUM_POLICY_ROLE_TYPE,
})
@Exposed()
type: ENUM_POLICY_ROLE_TYPE;
@ApiProperty({
type: RolePermissionDto,
oneOf: [{ $ref: getSchemaPath(RolePermissionDto) }],
required: true,
isArray: true,
default: [],
})
@Type(() => RolePermissionDto)
permissions: RolePermissionDto[];
}
Would love to hear thoughts, especially if there are concerns with the @Expose-based
approach (performance, swagger, testability etc).