Andrew Becker
Andrew Becker

Reputation: 41

Best practices for initializing Typescript classes from objects (Object.assign)

I want to define a TypeScript class that will receive it's properties via a plain object (like a database document).

class Car {
   color: string;
   
   constructor(props: ???) {
       Object.assign(this, props)
   }
 
}

What is the best way to type props?

I could create another interface, called CarProps and define them there, like:

class Car implements CarProps { 
    constructor(props: CarProps) {
        Object.assign(this, props)
    }
}

I am struggling with what to name the interface however, since the TypeScript Handbook forbids starting interface names with "I" (e.g. ICar). Since I would end up with an interface and class that define "Car", they both cant have the same name, so one of them has to be offset like "CarProps", "ICar", or "CarClass".

A problem with this approach is that the properties must be defined twice:

interface CarProps {
  color: string;
}

class Car implements CarProps {
  color: string; // duplication here
}

Some solutions are offered here (interface merging and abstract classes): Why the need to redefine properties when implementing an interface in TypeScript?

Would appreciate any suggestions on the best practice for this approach.

Upvotes: 3

Views: 2306

Answers (3)

Andrew Becker
Andrew Becker

Reputation: 41

I wanted to avoid the need for a separate interface that would require duplicating the property definitions (once for the class, once for the interface).

I found this custom TypeScript type:

type NonFunctionPropertyNames<T> = {
  [K in keyof T]: T[K] extends Function ? never : K
}[keyof T]
type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>

And it allowed me to make my class constructors like this:

constructor(props: NonFunctionProperties<Car>) {
  Object.assign(this, props)
}

So now I can do new Car(plainCarObject)! And plainCarObject will be properly type checked again all non-function properties on the class.

Upvotes: 1

mbdavis
mbdavis

Reputation: 4020

I think the CarProps approach is good, if that helps.

Calling it ICar or Car wouldn't make sense, but this is assuming that your class will have some other public fields/methods other than those in the interface you're creating.

If this is not the case, and you want some replica of fields in Car, then you could use Partial<Car>.

The problem with this is that it will include all public properties and methods in the Partial - so you allow the case where the method vroom can be overridden (see car2 below).


class Car {
  foo: number = 123;

  constructor(props: Partial<Car>) {
    Object.assign(this, props);
  }

  vroom = () => {
    console.log('vroom');
  }
}

const car = new Car({ foo: 567 });

// Should this be allowed?
const car2 = new Car({ foo: 567, vroom: () => console.log('hello') });


Upvotes: 2

Terry
Terry

Reputation: 66198

You can also make sure of TypeScript's implements keyword, which allows you to specify members in the class, which you can also use to type the incoming props argument. If props is not a partial field (aka all fields must be specified in the object), you can simply do this (see example on TypeScript playground):

interface Vehicle {
  color: string;
  model: string;
}

class Car implements Vehicle {
  color!: string;
  model!: string;
  
  constructor(props: Vehicle) {
    Object.assign(this, props)
  }

  public start(): void {
    console.log(`Staring ${this.model} that has the color ${this.color}`);
  }
}

const blackHonda = new Car({ color: 'black', model: 'Honda' });
blackHonda.start();

In this case, all fields in the Vehicle interface must be specified in the object passed into the constructor of the Car class.

However, if you wish for a partial interface, then it is a good idea to provide defaults, although it doesn't really make a lot of sense in this example (but might make sense in your implementation):

interface Vehicle {
  color: string;
  model: string;
}

class Car implements Vehicle {
  color: string = 'white';
  model: string = 'Tesla';
  
  constructor(props?: Partial<Vehicle>) {
    Object.assign(this, props)
  }

  public start(): void {
    console.log(`Staring ${this.model} that has the color ${this.color}`);
  }
}

const blackHonda = new Car({ color: 'black', model: 'Honda' });
blackHonda.start();

const defaultCar = new Car();
defaultCar.start();

See example on TypeScript playground.

Upvotes: 1

Related Questions