36

I want to assign properties to the instance of a class without knowing the property names, values and types of values in TypeScript. Lets assume we have the following example.ts script:

// This could be a server response and could look totally diffent another time...
const someJson:string = '{ "foo": "bar", "bar": "baz" }'

class MyClass {
  someProperty:boolean

  constructor( json:string ) {
    const parsedJson:any = JSON.parse( json )

    Object.keys( parsedJson ).forEach(
      ( key:string ) => {
        this[ key ] = parsedJson[ key ]
      }
    )

    this['someProperty'] = true
  }
}

const myInstance = new MyClass( someJson )

// Works fine, logs `true`.
console.log( myInstance.someProperty )

// Error: Property 'foo' does not exist on type 'MyClass'.
console.log( myInstance.foo )

// Error: Property 'bar' does not exist on type 'MyClass'.
console.log( myInstance.bar )

How can I make sure that the TypeScript compiler does not complain of the dynamically added properties but instead handle them as "key": value pairs of any type. I still want tsc to make sure that myInstance.someProperty has to be of type boolean but I want to be able to get myInstance.whatever even if it is not defined without running into compiler errors.

I did not find any documentation that makes this clear to me. Maybe because I'm not a native english speaker. So please keep the answers simple.

Edit:

I remember that there was something like the following but I never got that to work:

interface IMyClass {
  [name:string]: any
}
headacheCoder
  • 4,503
  • 8
  • 30
  • 33

3 Answers3

26

The problem is that you're adding the new properties at runtime and the compiler has no way of knowing that.

If you know the property names in advance then you can do this:

type Json = {
    foo: string;
    bar: string;
}

...

const myInstance = new MyClass(someJson) as MyClass & Json;
console.log(myInstance.foo) // no error

Edit

If you do not know the properties in advance then you can't do this:

console.log(myInstance.foo);

Because then you know that foo is part of the received json, you'll probably have something like:

let key = getKeySomehow();
console.log(myInstance[key]);

And this should work without an error from the compiler, the only problem with that is that the compiler doesn't know the type for the returned value, and it will be any.

So you can do this:

const myInstance = new MyClass(someJson) as MyClass & { [key: string]: string };
let foo = myInstance["foo"]; // type of foo is string
let someProperty = myInstance["someProperty"]; // type of someProperty is boolean

2nd edit

As you do know the props, but not in the class, you can do:

type ExtendedProperties<T> = { [P in keyof T]: T[P] };
function MyClassFactory<T>(json: string): MyClass & ExtendedProperties<T> {
    return new MyClass(json) as MyClass & ExtendedProperties<T>;
}

Then you simply use it like so:

type Json = {
    foo: string;
    bar: string;
};
const myInstance = MyClassFactory<Json>(someJson);

Note that this will work only on typescript 2.1 and above.

Nitzan Tomer
  • 155,636
  • 47
  • 315
  • 299
  • 1
    Interesting answer. But there is no way for me to know the properties in advance. I thought that extending an object with unknown property names at runtime should not be that exotic to plain JavaScript, so I'm wondering why this is a problem in TypeScript. – headacheCoder Dec 08 '16 at 12:08
  • So the only way to do `myInstance.foo` instead of `myInstance['foo']` would be to cast `myClass` to `any`, right? – headacheCoder Dec 08 '16 at 12:29
  • If you use `myInstance.foo` then it means that you know in advance that `foo` will be in the json, so you can use the method I showed in my original answer. If you don't know that it will be there then how can you have it in your code? – Nitzan Tomer Dec 08 '16 at 12:32
  • I see... Thank you for your explanation. My code is just an abstract example of a node module that I'm currently working on and it should load stuff on instantiation. So I know what properties are in the class when importing and using it in another file but the class itself does not know what properties and types it will assign to it. – headacheCoder Dec 08 '16 at 12:38
  • The answer doesn't resolved the question. And Typescript doesn't have a good answer for it. But I voted up since there seems no better alternative. – Robert Feb 05 '20 at 03:11
16

If you want to dynamically add class properties via an object upon instantiation, and type information is available for that object, you can very nicely get full type safety in this way (as long as you don't mind using a static factory method):

class Augmentable {
 constructor(augment: any = {}) {
   Object.assign(this, augment)
 }
 static create<T extends typeof Augmentable, U>(this: T, augment?: U) {
   return new this(augment) as InstanceType<T> & U
 }
}

This is using the (fake) this parameter to infer the constructor type of the class. It then constructs the instance, and casts it to a union of the instance type (using the InstanceType utility type) and the inferred type of the props you passed to the method.

(We could have casted directly to Augmentable & U, however this way allows us to extend the class.)

Examples

Augment basic properties:

const hasIdProp = Augmentable.create({ id: 123 })
hasIdProp.id // number

Augment with methods:

const withAddedMethod = Augmentable.create({
  sayHello() {
    return 'Hello World!'
  }
})


withAddedMethod.sayHello() // Properly typed, with signature and return value

Extend and augment, with this access in method augments:

class Bob extends Augmentable {
  name = 'Bob'
  override = 'Set from class definition'
  checkOverrideFromDefinition() {
    return this.override
  }
}

interface BobAugment {
  whatToSay: string
  override: string
  sayHelloTo(to: string): void
  checkOverrideFromAugment(): string
}

const bobAugment: BobAugment = {
  whatToSay: 'hello',
  override: 'Set from augment'

  sayHelloTo(this: Bob & BobAugment, to: string) {
    // Let's combine a class parameter, augment parameter, and a function parameter!
    return `${this.name} says '${this.whatToSay}' to ${to}!`
  },

  checkOverrideFromAugment(this: Bob & BobAugment) {
    return this.override
  }
}

const bob = Bob.create(bobAugment) // Typed as Bob & BobAugment
bob.sayHelloTo('Alice') // "Bob says 'hello' to Alice!"

// Since extended class constructors always run after parent constructors,
// you cannot override a class-set parameter with an augment, no matter
// from where you are checking it.
bob.checkOverrideFromAugment() // "Set from class definition"
bob.checkOverrideFromDefinition() // "Set from class definition"

Limitations

Augmented properties aren't really part of the class, so you can't extend a class with those augments included. This may be a feature for some use cases where the augments are temporary additions that aren't meant to modify the prototype hierarchy

It is also not easy to add non-augment arguments to .create(), however an easy work-around is to simply utilize the augment functionality to accomplish the same thing you would have with an extra argument.

Julien
  • 5,243
  • 4
  • 34
  • 35
11

You can add index signature to your class:

class MyClass {
  [index: string]: any; //index signature

  someProperty:boolean

  constructor( json:string ) {
    const parsedJson:any = JSON.parse( json )

    Object.keys( parsedJson ).forEach(
      ( key:string ) => {
        this[ key ] = parsedJson[ key ]
      }
    )

    this['someProperty'] = true
  }
}
vanowm
  • 9,466
  • 2
  • 21
  • 37