Named Constructors in TypeScript: Constructing Classes/Objects dart style

I was looking for a cleaner and reusable way to construct classes in TypeScript - like it is achieved using named constructors in Dart. I found a way to do this using a bit of help from ChatGPT, generics and the class-transformer library and thought I'd share it here. ChatGPT even co-authors this post with me, as I was too lazy to sum it up myself.

const john = new Human();
john.name = 'John';
john.age = '27';

const john = new Human({ name: 'John', age: '27' });

I will walk you through creating a reusable generic base class that allows for object construction in TypeScript, much like you might see in Dart.

Setting the Stage

Let's start with a simple Human class which extends from a BaseClass:

class Human extends BaseClass {
  name: string;
  age: string;
}

Our goal is to create a new instance of Human like this:

const john = new Human({ name: 'John', age: '27' });

We aim to pass an object during class instantiation, hence creating a constructor with named parameters.

Leveraging class-transformer

One of the libraries that can make this task easier is class-transformer. It offers a neat function, plainToInstance, which can convert plain JavaScript objects to class instances.

Crafting the BaseClass

Let's see how we can construct a BaseClass to achieve this:

import { plainToInstance } from 'class-transformer';

class BaseClass<T> {
  constructor(classObject: Partial<T> = {}) {
    Object.assign(this, plainToInstance(this.constructor as any, classObject));
  }
}

This base class uses TypeScript's generic types, where T is the type of the object passed to the constructor. It uses plainToInstance to convert this object into an instance of the class, then merges this instance with this using Object.assign.

Here's the catch: We're using this.constructor as any to bypass TypeScript's type system. The constructor will indeed be a function at runtime, so this is a safe cast.

It would be cool if we didn't have to make the class generic, and instead infer the type from the constructor. Unfortunately AFAIK, TypeScript doesn't allow us to do this. If you have a solution, please let me know in the comments!

Bringing it Together

Now we can define our Human class:

class Human extends BaseClass<Human> {
  name: string;
  age: string;
}

With this setup, TypeScript will check that the object you're passing to the constructor matches Partial<Human>, giving you compile-time checking for the properties. This means if you try to pass an object with properties not declared in Human, TypeScript will give you a type error:

// This is valid
const john = new Human({ name: 'John', age: '27' });
console.log(john);

// This will give a type error
// const invalidJohn = new Human({ someNonExistingProperty: '123', age: '27' });
// console.log(invalidJohn);

Wrapping Up

This technique offers a great way to create reusable and extensible base classes in TypeScript. It makes your code type-safe and allows you to leverage TypeScript's static type checking while beautifully creating new class instances.

Remember, this technique still depends on class-transformer, and it behaves according to any decorators you've placed on your class properties. If you have complex transformations, you will still need to annotate your properties with the appropriate decorators. But for plain properties like string, number, boolean, and so on, no decorators are needed.

Feel free to experiment with this pattern and see how it can make your TypeScript code cleaner and more understandable!

Using a static method instead of generic class

TypeScript does not provide a built-in way to infer the type of this within the class constructor, which is necessary to remove the generic from the base class.

This is a complex topic within TypeScript because of the way classes and the this keyword works. When you are in the constructor, the class instance hasn't been fully constructed yet, hence this is not fully typed. The type of this only becomes clear after the constructor has run and the instance has been constructed.

One way around this is to move the transformation logic to a static factory method. Here's how:

import { plainToInstance } from 'class-transformer';

class BaseClass {
  static create<T>(this: new () => T, plainObject: Partial<T>) {
    return plainToInstance(this, plainObject) as T;
  }
}

class Human extends BaseClass {
  name: string;
  age: string;
}

const john = new Human({ name: 'John', age: '27' }); // The old way, using 'new Human'
const john = Human.create({ name: 'John', age: '27' });
console.log(john);

In this code, the create method infers the type T from the this value, which is the class constructor when you call Human.create(...). It returns an instance of the class, created and initialized from the plain object.

Note: that this is not exactly the same as having the transformation logic in the constructor, because you're now calling a static method instead of using the new keyword to create the instance. But it does allow you to create typed instances from plain objects without making BaseClass generic. This solution also does not prevent you from creating a new instance of the class with the new keyword, you just need to make sure to also provide a standard constructor if you wish to allow this.

Comments