Skip to content

API Response DTO complex field exposure #589

@Gzerox

Description

@Gzerox

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:

  1. Look at the base class,
  2. Subtract what’s omitted,
  3. Subtract what’s excluded,
  4. 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:

  1. Use @Expose() on all fields meant to be returned
  2. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions