Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

discussion: custom class constructor #132

Closed
tonyxiao opened this issue Mar 5, 2018 · 10 comments
Closed

discussion: custom class constructor #132

tonyxiao opened this issue Mar 5, 2018 · 10 comments
Labels
status: wontfix type: discussion Issues discussing any topic.

Comments

@tonyxiao
Copy link

tonyxiao commented Mar 5, 2018

How should we handle situations where there are custom constructor for classes? E.g. if the class object I'm transforming into needs access to a database connection and I would like that to be passed into the constructor. Is it possible to still use plainToClass?

@NoNameProvided NoNameProvided added the type: question Questions about the usage of the library. label Mar 28, 2018
@NoNameProvided
Copy link
Member

NoNameProvided commented Mar 28, 2018

I don't know any way to do that at the moment.

@axiac
Copy link

axiac commented Nov 15, 2018

plainToClass() should not use new to instantiate the objects it creates. If the constructor has arguments and validates them, using new className() without arguments triggers false validation errors. (I'm not talking about the class-validator Npm package here, I'm talking about custom code one puts in the constructor).

plainToClass() can use Reflection to create an object of the desired type without calling its constructor:

newValue = Reflect.construct(Object, [], targetType);

in TransformOperationExecutor.ts line 119 (and probably in other places where it creates objects using new).

@MTschannett MTschannett added type: fix Issues describing a broken feature. status: help wanted and removed type: question Questions about the usage of the library. labels Nov 29, 2018
@NoNameProvided NoNameProvided added type: discussion Issues discussing any topic. and removed status: help wanted type: fix Issues describing a broken feature. labels Aug 2, 2020
@NoNameProvided NoNameProvided changed the title Custom class constructor discussion: custom class constructor Aug 8, 2020
@TimoGlastra
Copy link

@NoNameProvided I see you removed the help wanted label and added the discussion label.

Is this change still open for discussion or would you be willing to accept a PR? We use a lot of custom constructors and we now use the following pattern to avoid constructor errors:

class Test {
    constructor(options: TestType) {
        if (options) {
           // ... do things with constructor values
        }
}

This is suboptimal. Changing the internals to not call the constructor would be a big improvement for our project, so I'll be willing to invest the time.

@axiac
Copy link

axiac commented Oct 13, 2020

Using the constructor to create an object in plainToClass() is semantically incorrect. The role of the constructor is to initialize a new object. plainToClass() restores (from its properties) an object whose properties were extracted by classToPlain(). A common use case for this scenario is to get the object properties (classToPlain()), serialize and store them in the database or send them through an HTTP request then restore from these properties an object identical to the original. classToPlain() captures the state of the object then plainToClass() uses the state to restore the object. The life of the restored object should not start from being created with new but from the point where the properties of the original object were captured.

@TimoGlastra
Copy link

@axiac looking into this. There is only one place where new is called, so changing it was easy.

However I'm running into some problems related to default properties. The repo recently had a PR merged that will keep default properties in the class. e.g.:

class Test {
    propertyWithDefaultValue = 10
}

Because the constructor isn't called the default values aren't set. Would you know how to deal with this?

Failing tests
 FAIL  test/functional/default-values.spec.ts
  ● expose default values › should set default value if nothing provided

    expect(received).toEqual(expected) // deep equality

    - Expected  - 5
    + Received  + 3

    - Object {
    -   "adminWithDefault": false,
    + User {
        "age": undefined,
    -   "ageWithDefault": 18,
    +   "ageWithDefault": undefined,
        "firstName": undefined,
    -   "firstNameWithDefault": "default first name",
    -   "lastNameWithDefault": "default last name",
    +   "firstNameWithDefault": undefined,
      }

      33 | 
      34 |     expect(transformedUser).toBeInstanceOf(User);
    > 35 |     expect(transformedUser).toEqual({
         |                             ^
      36 |       age: undefined,
      37 |       ageWithDefault: 18,
      38 |       firstName: undefined,

      at Object.<anonymous> (test/functional/default-values.spec.ts:35:29)

  ● expose default values › should take exposed values and ignore defaults

    expect(received).toEqual(expected) // deep equality

    - Expected  - 3
    + Received  + 1

    - Object {
    -   "adminWithDefault": false,
    + User {
        "age": NaN,
        "ageWithDefault": NaN,
        "firstName": undefined,
        "firstNameWithDefault": undefined,
    -   "lastNameWithDefault": "default last name",
      }

      48 | 
      49 |     expect(transformedUser).toBeInstanceOf(User);
    > 50 |     expect(transformedUser).toEqual({
         |                             ^
      51 |       age: NaN,
      52 |       ageWithDefault: NaN,
      53 |       firstName: undefined,

      at Object.<anonymous> (test/functional/default-values.spec.ts:50:29)

@axiac
Copy link

axiac commented Oct 29, 2020

Not calling the constructors is a breaking change. I'm sure it breaks the functionality of many programs that use this package.

I would implement this change as an experimental feature that is disabled by default and can be enabled somehow by those who want to use it. This way it does not break the programs that use this package and can be released as a non-breaking new feature. From the semantic versioning point of view, this means an increase of the minor version.

The default behaviour can be changed to not call the constructors on the next major version of the package (the breaking changes require increasing the major version). Calling the constructor should still be available on the next major version as an opt-in, to allow the users of the package to upgrade to the new version without forcing them to use this new breaking-change feature.

Then, on the next major version (or after some time), the flag and the code that calls the constructors can be removed and the new behaviour becomes the only working way.

This is usually the flow to implement such a breaking change while keeping the backward compatibility with the previous versions of the package and letting the users of the package some time to accommodate the change into their code.

I recommend this approach because the changed behaviour is not visible in the interface of the package. The change is subtle and its effects might not be immediately visible.
In this example, the class does not have an explicit constructor declared in TypeScript, therefore it seems that not calling the constructor does not change the behaviour of the code. However, because of the properties with default values, the TypeScript compiler generates a constructor for the class (that initializes this.propertyWithDefaultValue) and not calling it changes the behaviour of the code.
Also, I'm sure most of the code in the wild that use this package either rely on the constructor being called or the developers do not realize that it is called and take its effect for granted.

@TimoGlastra
Copy link

I agree we should do this behind a feature flag.

However as the default property feature is already merged I can't see this ever being merged if default property values will be disposed.

I'm gonna take another look. Thanks for the exhaustive answer!

@Teedious
Copy link

Teedious commented Nov 3, 2020

For anyone struggling right now:

I don't know if my workaround is valid but currently I simply create a wrapper type and a custom transform (example with luxon):

import { DateTime} from 'luxon';
export type DT = DateTime;

export function toDateTime(value: string | Date | DateTime): DateTime {
  if (typeof value === 'string') return DateTime.fromISO(value);
  if (value instanceof Date) return DateTime.fromJSDate(value);
  if (value instanceof DateTime) return value;
  throw new Error('Wrong entity type');
}

Then I use this type and transform in my class:

export class Meeting {
   ...
  @Transform(value => toDateTime(value), { toClassOnly: true })
  start: DT;
  ...
}

I hope this helps someone.

@NoNameProvided
Copy link
Member

After thinking about this, nothing like this will be implemented. However, the desired behavior is easily achievable with typedi. In your @Transform decorator you can use a TypeDI container to request your dependencies.

import { Container } from 'typedi';
import { Transform } from 'class-transformer';

export class MyClass {
  @Transform({ value } => Container.get(MyService).doSomeStuff(value))
  someProperty!: any;
}

@github-actions
Copy link

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.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Mar 17, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
status: wontfix type: discussion Issues discussing any topic.
Development

No branches or pull requests

6 participants