typestack / class-transformer

Decorator-based transformation, serialization, and deserialization between objects and classes.
MIT License
6.81k stars 500 forks source link

question: how to use exposing method inside a nested object array dto? #1608

Closed andreasvh-conceto closed 4 months ago

andreasvh-conceto commented 1 year ago

Hello,

i was trying to serialize a Dto in a jest test, which contains a nested array of another dto.

import { Expose, Type, instanceToPlain } from "class-transformer";
import "reflect-metadata";

export class AlbumDto {
  id: number;
  name: string;

  @Type(() => PhotoDto)
  photos: PhotoDto[];

  // here are not any issues
  @Expose({ name: "title" })
  getTitle(): string {
    return "My super album";
  }

  constructor(partial: Partial<AlbumDto>) {
    Object.assign(this, partial);
  }
}

export class PhotoDto {
  @Expose({ name: "file" })
  filename: string;

  constructor(partial: Partial<PhotoDto>) {
    Object.assign(this, partial);
  }

  @Expose({ name: "author" })
  public getAuthor(): string {
    return "John";
  }
}

describe("transmission detail response dto serialization test", () => {
  it("learning test nested objects", () => {
    const albumPartial = {
      id: 1,
      name: "My Album",
      photos: [{ filename: "photo1.jpg" }, { filename: "photo2.jpg" }],
    };

    const photoPartial = {
      filename: "somephoto.jpg",
    };

    // this results into an error

    const album = new AlbumDto(albumPartial);
    const photo = new PhotoDto(photoPartial);
    console.log(instanceToPlain(photo));
    console.log(instanceToPlain(album));
  });
});

The problem: I receive the following error: const album = new AlbumDto(albumPartial); The error:

src/transmission/dtos/transmission-detail-response.dto.spec.ts:50:32 - error TS2345: Argument of type '{ id: number; name: string; photos: { filename: string; }[]; }' is not assignable to parameter of type 'Partial<AlbumDto>'.
      Types of property 'photos' are incompatible.
        Type '{ filename: string; }[]' is not assignable to type 'PhotoDto[]'.
          Property 'getAuthor' is missing in type '{ filename: string; }' but required in type 'PhotoDto'.

    50     const album = new AlbumDto(albumPartial);
                                      ~~~~~~~~~~~~

      src/transmission/dtos/transmission-detail-response.dto.spec.ts:31:10
        31   public getAuthor(): string {
                    ~~~~~~~~~
        'getAuthor' is declared here.

Here iam a little bit confused, why this error is happening for the nested dto? - it is also instantiated via a partial! If i remove the getAuthor method from the PhotoDto everything is fine. So on the first level there are no issues adding methods. But i would also not expect any issues in the nested levels, because it is just a partial i need to pass to the constructor right? So property name would be enough in the albumPartial.photos objects.

How to solve the issue in the best way? I know that i can also instantiate new PhotoDto in the album like this:

const albumPartial = {
      id: 1,
      name: "My Album",
      photos: [
        new PhotoDto({ filename: "photo1.jpg" }),
        new PhotoDto({ filename: "photo2.jpg" }),
      ],
    };
const album = new AlbumDto(albumPartial);

But this looks ugly and enforces me to do this for every nested object in my albumPartial. Not to imagine having nested in nested Objects.

Any solutions or ideas?

Thanks and best regards

diffy0712 commented 5 months ago

Hi @andreasvh-conceto, Thank you for the detailed issue and sorry for a late reply.

The error you got is a typescript error and nothing to do with this library. The error clearly states that 'Types of property 'photos' are incompatible.' You used the Partial<AlbumDto>, but AlbumDto contains 'photos: PhotoDto[]', which is not Partial, so when you instantiate with an object containing photos, it will require you to be compatible with 'PhotoDto'. (so Partial is not a deep Partial).

But this is not the way you would want to use class-transformer. Here is a snippet for your scenario which will work:

class AlbumDto {
  id: number;
  name: string;

  @Type(() => PhotoDto)
  photos: PhotoDto[];

  @Expose()
  get title(): string {
    return "My super album";
  }
}

class PhotoDto {
  @Expose()
  filename: string;

  @Expose()
  get author(): string {
    return "John";
  }
}

describe("transmission detail response dto serialization test", () => {
  it("learning test nested objects", () => {
    const albumPartial = {
      id: 1,
      name: "My Album",
      photos: [{ filename: "photo1.jpg" }, { filename: "photo2.jpg" }],
    };

    const album = plainToInstance(AlbumDto, albumPartial);
    expect(album.id).toEqual(1);
    expect(album.name).toEqual('My Album');
    expect(album.photos[0].filename).toEqual('photo1.jpg');
    expect(album.photos[1].filename).toEqual('photo2.jpg');

    expect(instanceToPlain(album)).toEqual({
      id: 1,
      name: 'My Album',
      photos: [
        { filename: 'photo1.jpg', author: 'John' },
        { filename: 'photo2.jpg', author: 'John' }
      ],
      title: 'My super album'
    });
  });
});
andreasvh-conceto commented 4 months ago

Thank you for the detailed explanation. This helps! Best regards :)

andreasvh-conceto commented 4 months ago

Hi @diffy0712 one question came into my mind, while playing around with your answer. When i choose to @Expose({name: "myPropertyName"}) the plainToInstance method returns undefined for the annotated property. Currently i handle that using the ignoreDecorators: true option like this:

    const album = plainToInstance(AlbumDto, albumPartial, {
      ignoreDecorators: true, // this prevents that the property will be undefined, since it is keeping the property name from the created instance
    });

Is this the right approach to go for?

Thanks a lot and best regards

diffy0712 commented 4 months ago

Hello @andreasvh-conceto,

if you provide @Expose({name: "myPropertyName"}) decorator to the name property, when calling plainToInstane, it will expect your plain to have a property called 'myPropertyName' instead of the real property name, which I assume you did not provide in your plain object, so it was set to undefined.

I would not suggest to use ignoreDecorators for this, as it is the same as not using the decorator in your situation.

So updating my previous example:

// if you add 

@Expose({name: "myPropertyName"})
name: string;

// then you plain would needs to change to 
const albumPartial = {
  id: 1,
  myPropertyName: "My Album",
  photos: [{ filename: "photo1.jpg" }, { filename: "photo2.jpg" }],
};

NOTE: if you override the name in expose, then you will get that name in your instanceToPlain result as well. If you need to override name only on one way please use the options toPlainOnly or toClassOnly. @Expose({name: "myPropertyName", toClassOnly: true})

Please read the documentation a bit more in detail, because everything I have wrote here is already in the documentations!

Hope this helps.

github-actions[bot] commented 3 months ago

This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.