Considering the process of converting one class definition (CreateDTO
) into another class definition (UpdateDTO
) can be problematic. The initial class definition provided as an example, CreateDTO, fails to properly initialize its required properties. Enabling the --strictPropertyInitialization compiler option, which is part of the --strict
features suite, leads to identifying and fixing these initialization errors. This necessitates considering how instances of the class should be constructed by passing constructor arguments for the properties:
class CreateDTO {
id: string;
name: string;
price: number;
constructor(id: string, name: string, price: number) {
this.id = id;
this.name = name;
this.price = price;
}
}
The approach of extending Partial<CreateDTO>
with class UpdateDTO
is not straightforward due to limitations of type extension in TypeScript. Instead, a Partial
function can be created to extend the class constructors of a generic type T
to that of Partial<T>
:
function Partial<A extends any[], T extends object>(ctor: new (...args: A) => T) {
const ret: new (...args: A) => Partial<T> = ctor;
return ret;
}
Once the Partial
function is applied, it allows creating
class UpdateDTO extends Partial(CreateDTO)
. However, this doesn't completely resolve the issue as the new class still requires constructor arguments inherited from
CreateDTO
.
Rather than viewing UpdateDTO
as directly derived from CreateDTO
, a more effective perspective is transforming interface definitions into class definitions. Creating a factory function that takes an object type definition and generates a class constructor provides better flexibility:
function DTOClass<T extends object>() {
return class {
constructor(arg: any) {
Object.assign(this, arg)
}
} as (new (arg: T) => T);
}
The use of Object.assign()
in the implementation spreads input arguments into the instance being constructed. This approach results in classes like CreateDTO
and UpdateDTO
produced using the factory function:
interface CreateDTO {
id: string;
name: string;
price: number;
}
const CreateDTO = DTOClass<CreateDTO>();
const c = new CreateDTO({ id: "abc", name: "def", price: 123 });
c.price = 456;
console.log(c); // { "id": "abc", "name": "def", "price": 456 }
}
interface UpdateDTO extends Partial<CreateDTO> { }
const UpdateDTO = DTOClass<UpdateDTO>();
const u = new UpdateDTO({});
u.name = "ghi";
console.log(u); // { "name": "ghi" }
This method ensures compatibility between class constructors and object types both during compilation and at runtime.
An improvement could be made to allow class constructors that accept optional arguments when the object type has no mandatory properties. By introducing a conditional type in DTOClass
, the argument requirement becomes optional if an empty object would suffice as input:
function DTOClass<T extends object>() {
return class {
constructor(arg: any) {
Object.assign(this, arg)
}
} as {} extends T ? (new (arg?: T) => T) : (new (arg: T) => T);
}
With this modification, classes like UpdateDTO
can now be instantiated without requiring any arguments.
Playground link here