Home > other >  TypeScript: Adding dynamic fields to a class at runtime with type safety
TypeScript: Adding dynamic fields to a class at runtime with type safety

Time:10-18

I'm converting some legacy JS code to TS, and have run into an interesting typing situation. There is a module system that is loading class instances at runtime onto named properties of an instance of another class, and am trying to figure out how to type the object:

class Module {
  public name: string;
}

class ThisModule extends Module {
  public name = 'thisModule';

  public log = () => {
    console.log('ThisModule called!');
  };
}

class ThatModule extends Module {
  public name = 'thatModule';

  public log = () => {
    console.log('ThatModule called!');
  };
}

interface IModule extends Module {}

type ModulesInput = Module[];

class Container {
  public init = (modules: ModulesInput) => {
    for (const module of modules) {
      this[module.name] = module;
    }
  };
}

const thisModule = new ThisModule();
const thatModule = new ThatModule();

const container = new Container<ThisModule | ThatModule >();

const modules: ModulesInput = [
  thisModule,
  thatModule,
];
container.init(modules);

// Should not throw TS error
container.thisModule.log();
container.thatModule.log();

// Should throw error ("Property anythingElse does not exist on Container")
container.anythingElse.log();

The problem is that TypeScript is not recognizing that container.thisModule or container.thatModule should exist. It says TS2339: Property 'thisModule' does not exist on type 'Container'. (and also for thatModule, the same).

Is there a way to type this system? I have had some limited success so far adding multiple generics to the Container class (e.g. type keys = 'thisModule' | 'thatModule' and type ModulesInUse = ThisModule | ThatModule), but can TypeScript discover the names from the classes and dynamically learn that it should expect those keys on the container object to have types of their respective classes?

Thanks in advance!

CodePudding user response:

Typescript is limited by a notion that a class cannot have "dynamic fields" since those are really hard to typecheck within the class, however unions of the right fields and the class is valid and writing the correct type contracts in functions is very possible, there are 2 ways you can obtain the relevant info:

Option 1: have the name property typed as a literal

If the property name of your modules are typed not as string but as the literal string that it contains there is more room to leverage that later,

abstract class Module<Name extends string> {
  public abstract name: Name;
}

class ThisModule extends Module<'thisModule'> {
  public name:'thisModule' = 'thisModule';

  public logThis = () => { console.log('ThisModule called!'); };
}
class ThatModule extends Module<'thatModule'> {
  public name:'thatModule' = 'thatModule';

  public logThat = () => { console.log('ThatModule called!'); };
}

With this you could define the container type you are looking for with something like this:

type TypesafeContainer<ModuleUnion extends Module<string>> = {
    [K in ModuleUnion["name"]]: ModuleUnion["name"] extends K ? ModuleUnion : never;
}

This takes a generic that is a union of multiple modules and generates the type you are looking for:

declare function makeContainer<T extends Module<string>>(modules: T[]): TypesafeContainer<T>

const thisModule = new ThisModule();
const thatModule = new ThatModule();

const container = makeContainer([thisModule, thatModule]);

// does not throw TS error
container.thisModule.logThis();
container.thatModule.logThat();

// Should throw error ("Property anythingElse does not exist on Container")
container.anythingElse.log();

One problem is that if you try to declare a class Container to extend this type you get an error saying "An interface can only extend an object type or intersection of object types with statically known members."

class Container<T extends Module<string>>  {
  public constructor(modules: T[]) {
    for (const [module] of modules) {
      this[module.name] = module;
    }
  };
}
interface Container<T extends Module<string>> extends TypesafeContainer<T>{}
                                                     //^^ this is not allowed :(

I think this is mainly a limitation of typescript, there isn't any theoretical issue with this construct.

Option 2: Pick from an interface of all allowed modules

it is possible that carrying around a generic with every module might be annoying and if you drop the generic for name anywhere you may run into more obscure typing issues, an alternative is to explicitly list an interface with all supported modules:

interface CONTAINER_ALL_MODULES {
    thisModule: ThisModule
    thatModule: ThatModule
}
// still not valid unfortunately :(
interface Container2<K extends keyof CONTAINER_ALL_MODULES> extends Pick<CONTAINER_ALL_MODULES, K>{
    new(modules: Array<CONTAINER_ALL_MODULES[K]>): this
}

This interface for container runs into the same limitation that you can't extend a type with dynamic members, but a function with the correct type signature is still possible:

class RealContainer {
    // actual stuff here
}

declare function makeContainer2<K extends keyof CONTAINER_ALL_MODULES>(modules: Array<CONTAINER_ALL_MODULES[K]>): RealContainer & Pick<CONTAINER_ALL_MODULES, K>

const container2 = makeContainer2([thisModule, thatModule]);
// does not throw TS error
container2.thisModule.logThis();
container2.thatModule.logThat();

// Should throw error ("Property anythingElse does not exist on Container")
container2.anythingElse.log();

Hopefully this is helpful, hope you find something that works for you :)

ts playground

CodePudding user response:

In order to get rid error from this line this[module.name] = module; you need to provide index signature: [prop: string]: Module.

Please be aware, that typescript don't track mutation except one case, hence you need to return new this from init property.

Whole code:

class Module {
  public name: string = '' // need to be initialized
}

class ThisModule extends Module {
  public name = 'thisModule' as const;

  public log = () => {
    console.log('ThisModule called!');
  };
}

class ThatModule extends Module {
  public name = 'thatModule' as const;

  public log = () => {
    console.log('ThatModule called!');
  };
}


type Reduce<
  T extends ReadonlyArray<Module>,
  Acc extends Record<string, unknown> = {}
  > =
  (T extends []
    ? Acc
    : (T extends [infer Head, ...infer Tail]
      ? (Tail extends Module[]
        ? (Head extends Module
          ? Reduce<Tail, Acc & Record<Head['name'], Head>>
          : never)
        : never)
      : never)
  )

class Container {
  [prop: string]: Module; // you need to provide index signature to class in order to use  this[module.name] = module

  public init = <M extends Module, Modules extends M[]>(modules: [...Modules]) => {
    for (const module of modules) {
      this[module.name] = module;
    }

    return this as Reduce<[...Modules]>
  };
}

const thisModule = new ThisModule();
const thatModule = new ThatModule()

const container = new Container();


const newContainer = container.init([
  thisModule,
  thatModule,
]);


// Should not throw TS error
newContainer.thisModule.log()
newContainer.thatModule.log();

// Should throw error ("Property anythingElse does not exist on Container")
newContainer.anythingElse.log() // error

Playground

I have used Reduce utility type, it works almost like Array.prototype.reduce, it folds all element from the tuple into one object.

Also, I have used variadic-tuple-types to infer each module from init argument <M extends Module, Modules extends M[]>(modules: [...Modules]).

  • Related