Search code examples
typescriptreflection

Setting object property by string name


Very new to typescript and am trying to port some C# code. I need to be able to set an instances properties by property string name.

In C# I do this with reflection, but how can I do this in typescript?

The following is a simplified example:

class BaseClazz {
  baseA?: string;
  baseB?: string;
}

class DerivedClazz extends BaseClazz {
  derived1?: number;
  derived2?: number;
  array1?: number[];
  array2?: number[];
}

I want a function that can set the various properties by their string name:

function assign(clazz: BaseClass, propertyName: string, value: any) {
  ...
}

In theory I can call it as follows and each of the 4 properties would be set:

test:BaseClazz = new DerivedClazz();
assign(test, "baseA", "a");
assign(test, "baseB", "b");
assign(test, "derived1", 1);
assign(test, "derived2", 2);
assign(test, "array1", [1, 2, 3, 4]);
assign(test, "array2", [5, 6, 7, 8]);


Solution

  • Since all object types in JavaScript are essentially property bags, you can write assign() as a generic function that accepts any object for its first argument (not just a subtype of BaseClazz):

    function assign<T extends object, K extends keyof T>(
        obj: T, key: K, val: T[K]
    ) {
        obj[key] = val; // okay
    }
    

    Here obj is of generic type T constrained to object, and key is of generic type K constrained to the keys of T (using the keyof type operator), and val is of the indexed access type T[K], which is the type of the property at obj[key].

    If you really want to limit the function to be for subtypes of BaseClazz you can write T extends BaseClazz instead of T extends object, but unless you need other functionality of BaseClazz inside assign() there's not much reason to.


    Let's make sure this works:

    const test = new DerivedClazz();
    assign(test, "baseA", "a"); // okay
    assign(test, "baseB", 123); // error!
    // -----------------> ~~~
    // Argument of type 'number' is not assignable to parameter of type 'string'
    
    assign(test, "baseB", "b"); // okay
    assign(test, "baseC", "x"); // error!
    // Argument of type '"baseC"' is not assignable to parameter of type 'keyof DerivedClazz'
    
    assign(test, "derived1", 1); // okay
    assign(test, "derived2", 2); // okay
    assign(test, "array1", [1, 2, 3, 4]); // okay
    assign(test, "array2", [5, 6, 7, 8]); // okay
    

    Looks good. The compiler complains if you pass in a key that is not known to exist on test, and it complains if you pass in a value that is not of the type of the property corresponding to indexing into test with the key at key.


    This is about as type-safe as TypeScript typically gets for generics. It's not perfectly sound. For example, if the key is of a union type then the value is also allowed to be of the corresponding union type, which could be weird:

    assign(test, Math.random() < 0.999 ? "derived1" : "array1", [1]); // okay, 
    // but 99.9% chance of being bad
    

    But unsoundness runs through the language, intentionally, for better or worse (see this comment on microsoft/TypeScript#9825). There are other ways to put the wrong thing into a property, since TypeScript treats object types as covariant in their properties, which again, could be weird:

    interface Foo {
        baseA?: string | number;
    }
    const foo: Foo = test; // okay because DerivedClazz extends Foo
    assign(foo, "baseA", 123); // okay, but oops
    foo.baseA = 456; // same thing directly
    console.log(test.baseA.toUpperCase()); // 💥 RUNTIME ERROR!
    

    So take care when combining subtyping with property assignment!

    Playground link to code