Typescript - adding to existing classes without duplication



  • I'm in the process of moving my codebase over to typescript. I've read through the typescript documentation and the typescript deep dive (highly recommended by the way), and I feel like I have a reasonable understanding of it now.

    However, I haven't found any sensible way to extend the builtin prototypes (e.g. Creep, Room, etc). So far the only way is to use Declaration Merging to add the declarations to the interface, and then define them separately like this:

    interface Creep {
        main(): void;
    }
    
    Creep.prototype.main = function () {
        // ...
    };
    

    I have two major issues with this:

    1. It feels like a lot of duplication. Even if I keep all the type information in the interface and don't even bother to annotate the types in the actual function, it's still pretty horrible.
    2. The safety is lacking, which rather defeats the point of using typescript. If I add a method to the interface but forget to actually define it, typescript will tell me nothing.

    I was hoping I could declare the existing interface to also implement a class I've defined, and then I could dynamically mixin all the methods like this:

    class CreepExtended {
        main(): void {
            // ...
        }
    }
    
    interface Creep implements CreepExtended {}
    
    Object.getOwnPropertyNames(CreepExtended.prototype).forEach(name => {
        Object.defineProperty(Creep.prototype, name, Object.getOwnPropertyDescriptor(CreepExtended.prototype, name));
    });
    

    But this doesn't seem to work. Typescript will still give errors like Property 'main' does not exist on type 'Creep'.

    All of the public codebases I've seen are wrapping everything in their own new objects. I feel like I'm going to have to resort to this, but it seems like a lot of effort with a negative performance impact for no benefit - it's just to keep typescript happy.

    Has anyone managed to achieve this?

    Thanks.



  • Here is my current solution.

    I have several creep extension classes spread across different modules that I inject into Creep.prototype.

    function mergePrototypes(klass: any, extra: any) {
            const descs = Object.getOwnPropertyDescriptors(extra.proto);
            delete descs.constructor;
            Object.defineProperties(klass.proto, descs);
    }
    
    function injecter(klass: any) {
        return function (extra: any) {
            merge(klass, extra);
        }
    }
    
    @injector(Creep)
    class CreepExtra extends Creep {}
    
    // or if you don't want to polyfill class decorators
    class CreepMove extends CreepExtra {}
    merge(Creep, CreepMove);
    
    // ...
    
    class CreepRepair extends CreepCarry {}
    merge(Creep, CreepRepair);
    

    For the most part the types just work. I have a few methods that I needed to explicitly interface merge in to prevent cyclic imports. I've debated whether to merge all the extension classes into a single file, but so far I haven't needed to. Occasionally I need to type cast this.room.find(FIND_MY_CREEPS) as CreepMove[], but I hasn't become enough of an issue.

    The other big advantage of this mode is that VSCode go to definition takes me to the code rather than the interface.



  • Surely you'd have to typecast everywhere for that to work? Or is it just that in practice you only need to typecast in a few strategic places?

    What about things like helper methods on RoomPosition?



  • FWIW I extend the type via Declaration merging, and it at least prevents me extending a the prototype without having declared it up front.



  • I only use the inheritance chain for Creep. It has completely eliminated all this casts from my code.

    For all of my other modifications I do it with a single class in a single file.

    I also have serveral mixins shared among different types. I keep the mixin class and interface declarations in a single file.

    My Room extensions are kind of a mess half are in a single file the rest are sprinkled wherever and all of the declaration are in types.d.ts. I'm not happy with it but it hasn't climbed my annoyance list enough to fix it.



  • @deft-code said in Typescript - adding to existing classes without duplication:

    I only use the inheritance chain for Creep. It has completely eliminated all this casts from my code.

    Is that because you're just calling creep.main() for each creep and then you're already inside CreepExtra when making any other calls to extension methods? I've got quite a few extensions that get called on the results of things like room.find(FIND_HOSTILE_CREEPS), which obviously still returns a Creep[], so typescript doesn't know those extensions are available. The obvious solution is to cast every find result to a CreepExtra[], but this is the kind of thing I'm hoping to avoid.

    Perhaps I should make room.findCreeps, pos.findClosestCreepByRange, pos.findCreepsInRange, etc.

    For all of my other modifications I do it with a single class in a single file. I also have serveral mixins shared among different types. I keep the mixin class and interface declarations in a single file.

    Could you give some examples of these? I'm not sure what you mean by modifying "with a single class".

    My Room extensions are kind of a mess half are in a single file the rest are sprinkled wherever and all of the declaration are in types.d.ts. I'm not happy with it but it hasn't climbed my annoyance list enough to fix it.

    I think I've come up with something reasonably sensible for Room extensions. There are only a few places where you ever get a reference to a room, so it's not too bad to override them all like this:

    interface Creep {
        room: RoomExtra;
    }
    
    interface Game {
        rooms: { [roomName: string]: RoomExtra };
    }
    
    interface RoomExtraObject {
        room: RoomExtra | undefined;
    }
    
    interface Source {
        room: RoomExtra;
    }
    
    interface Structure {
        room: RoomExtra;
    }
    
    interface OwnedStructure {
        room: RoomExtra;
    }
    

    EDIT: Ignore this, it doesn't actually work - typescript complains that Subsequent property declarations must have the same type

    To be sure, this feels like something that's really lacking from typescript - the ability to declare that an existing interface is being extended with everything from a new class.



  • It's not possible to override the types with using interface merging. However you can override the types in a class. I've done that (my CreepRole.ticksToLive is number rather than number|undefined. I've only tried it with a Cyclic definitions but in theory it should work with dynamic imports. It's ugly for sure but you only have to do it once.

    //room.extra.ts
    export class RoomExtra extends Room {
      get allCreeps() {
        return this.find(FIND_CREEPS) as import('creep.extra').CreepExtra
      }
      get hostileCreeps(){
        return this.find(FIND_HOSTILE_CREEPS) as import('creep.extra').CreepExtra
      }
      get myCreeps() {
            return this.find(FIND_MY_CREEPS, {filter = (c) => !c.spawning}) as import('creep.role').CreepRole
      }
      get spawningCreeps() {
            return this.find(FIND_MY_CREEPS, {filter = (c) => c.spawning}) as import('creep.role').CreepSpawning
      }
    }
    
    // creep.extra.ts    
    class CreepExtra extends Creep {
      room: import('room.extra').RoomExtra
      ttl: number
      spawning: false
    }
    
    // creep.role.ts
    class CreepRole extends CreepExtra {
    }
    
    class CreepSpawning extends Creep {
      ttl: undefined
      spawning: true
    }
    

    There really should be a typescript-starter++ that sets up best practice for extending the base types. It could also include all the most common extensions like per structure accessors.

    👍