Skip to content

Instantly share code, notes, and snippets.

@shirakaba
Last active January 11, 2019 13:20
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save shirakaba/b23c1a77098678499ad55707ccc6a17c to your computer and use it in GitHub Desktop.
Save shirakaba/b23c1a77098678499ad55707ccc6a17c to your computer and use it in GitHub Desktop.
DI, decorators, and mixins notes for TypeScript

Dependency Injection

Dependency Injection (DI) is used to invert control in portions of a program. Here I focus on the concrete use-case of provisioning a class with a logger implementation that keeps a class-instance loggingInfo object so that any call to the logger will always include the info from loggingInfo (e.g. an id for a request that the class instance was constructed solely to handle) in its log messages.

In my implementations, I refer to global.wi, global.wd and other similarly cryptically-named methods; these stand for "Winston info-level log" and "Winston debug-level log", etc. (where Winston is the logging library I normally use). At the start of the program, it is assumed that one would register an implementation to these variables – typically a call to a Winston logger, but could equally be substituted for console.log.

Patterns

I wanted to investigate two key ways of augmenting existing classes, ultimately to achieve dependency injection: decorators and mixins. In TypeScript, this involves two aspects: firstly, conforming to the augmented interface, and secondly, supplying the augmentation's implementation.

By seeing how it all works, I hoped to gain a better understanding of what dependency injection is, what it involves, and what it can be used for – all in the context of TypeScript.

Declaration of conformance to interface

Before choosing one of either decorators or mixins for augmenting our classes, we must first satisfy TypeScript by assuring it that our classes will conform to the (augmented) interface.

Via implements keyword

More explicit and uses easier concepts, but mires us with boilerplate.

import {Logger as WinstonLogger} from "winston";

export class LoggingInstanceMixin {
    loggingInfo: any = {
        mixin: true
    };

    /* Can consider checking for existence of global.we first, or injecting it as a dependency. */
    we(message: string, data?: any): WinstonLogger {
        return global.we(message, { ...this.loggingInfo, ...data});
    }
    ww(message: string, data?: any): WinstonLogger {
        return global.ww(message, { ...this.loggingInfo, ...data});
    }
    wi(message: string, data?: any): WinstonLogger {
        return global.wi(message, { ...this.loggingInfo, ...data});
    }
    wd(message: string, data?: any): WinstonLogger {
        return global.wd(message, { ...this.loggingInfo, ...data});
    }
}

export class MyServer extends ExpressApp implements LoggingInstanceMixin {
    readonly loggingInfo: any = {
        birth: Date.now()
    };

    /* Mandatory method stubs to prevent design-time errors. */
    we!: (message: string, data?: any) => WinstonLogger;
    ww!: (message: string, data?: any) => WinstonLogger;
    wi!: (message: string, data?: any) => WinstonLogger;
    wd!: (message: string, data?: any) => WinstonLogger;

    /* Can access all properties of LoggingInstance without design-time errors. */
}
/* Still need some way to provide the mixin, e.g. applyMixins(), to satisfy run-time. */

Via interface declaration merging

Preferring this; no practical disadvantage at all.

export interface LoggingInstance {
    readonly loggingInfo: LoggingInfo;
    we(message: string, data?: any): WinstonLogger;
    ww(message: string, data?: any): WinstonLogger;
    wi(message: string, data?: any): WinstonLogger;
    wd(message: string, data?: any): WinstonLogger;
}

export interface MyServer extends LoggingInstance {} // Leave body blank.
export class MyServer extends ExpressApp {
    /* No need to provide method stubs just to prevent compile-time errors. */
}
/* Still need some way to provide the mixin, e.g. applyMixins(), to satisfy run-time. */

Implementation of interface

In these examples, we'll go with interface declaration merging as our strategy for declaring conformance to the interface.

Via applyMixins()

Not as clean as a decorator, as applyMixins() must be added strictly after class declaration, so we have logic strewn across the file. Mixins, at least with the standard applyMixins() implementation, only alter a class's prototype fields. By transpiling a TypeScript class down to ES5 JS, it is clear that this constitutes only class instance methods. This means that mixins:

  • DO alter class instance methods: MyServer.prototype.we
  • COULD POTENTIALLY alter class static methods: MyServer.someStaticMethod, but would need a different applyMixins() implementation that updates static methods by iterating the keys of both MyServer and the mixin's baseCtors.
  • COULD POTENTIALLY alter class fields: this.someClassField = valueFromConstructor, but would need a different applyMixins() implementation. It could copy MyServer.prototype.constructor to a variable called MyServer.prototype.originalConstructor and then run function MyServer(valueFromConstructor){ this.originalConstructor(valueFromConstructor); this.someClassField = valueFromMixin; }
import {Logger as WinstonLogger} from "winston";

export class LoggingInstanceMixin {
    /* This field doesn't get applied at all – regardless of whether target class has defined
     * loggingInfo or not. This is because applyMixins() only alters the class's prototype fields
     * (which include instance methods, but not instance properties, nor static methods/fields).
     * It is */
     // loggingInfo: any = {
     //     mixin: true
     // };

    /* These DO get called and DO have access to the target class instance's 'this' context.
     *
     * By declaring class instance functions in this TypeScript syntax, they are added to the prototype.
     * This is imperative for applyMixins(), which in its current implementation only concerns itself
     * with the prototype, to work */
    we(message: string, data?: any): WinstonLogger {
        return we(message, { ...this.loggingInfo, ...data});
    }
    ww(message: string, data?: any): WinstonLogger {
        return ww(message, { ...this.loggingInfo, ...data});
    }
    wi(message: string, data?: any): WinstonLogger {
        return wi(message, { ...this.loggingInfo, ...data});
    }
    wd(message: string, data?: any): WinstonLogger {
        return wd(message, { ...this.loggingInfo, ...data});
    }
}

/* Looks like this would probably survive name-mangling from minification. */
export function applyMixins(derivedCtor: any, baseCtors: any[]): void {
    baseCtors.forEach(baseCtor => {
        Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => {
            derivedCtor.prototype[name] = baseCtor.prototype[name];
        });
    });
}

export interface MyServer extends LoggingInstance {} // Leave body blank.
export class MyServer extends ExpressApp {
    loggingInfo: any = {
        birth: Date.now() // This DOES appear in the logs. The Mixin doesn't override it.
    };
}
applyMixins(MyServer, [LoggingInstanceMixin]);

Output:

LoggingMixin_1.applyMixins(MyServer, [LoggingMixin_1.LoggingInstanceMixin]);

Via decorator

Very clean; all the logic goes at the top of the class. Decoration gives us access to post-construction class instance members, which is ideal (the class will get constructed, and then our decorator can update its properties – in this case, loggingInfo – as desired).

export function DILoggingInstance<T extends {new(...args:any[]):{}}>(constructor:T) {
    return class extends constructor {
        /* loggingInfo = {
         *     DI: true // This overrides the target's loggingInfo.
         * }; */
        // loggingInfo: any; // This stub would simply use the target's existing loggingInfo.
        /* This merges any existing info into this local one. */
        loggingInfo: any = {
            ...this.loggingInfo,
            DI: true
        };
        /* These DO get called and DO have access to the target class instance's 'this' context.
         *
         * By declaring class instance functions in this TypeScript syntax, they become implicitly-
         * bound class instance fields. The decorator approach supports this, unlike mixins.
         *
         * Out of interest, if implementations for we, ww, wi, or wd are already declared on the
         * prototype, then these instance functions (being 'own properties') will take priority in
         * the prototype chain. Source:
         * https://github.com/tc39/proposal-decorators/blob/master/bound-decorator-rationale.md#mocking
         */
        we = (message: string, data?: any) => global.we(message, { ...this.loggingInfo, ...data});
        ww = (message: string, data?: any) => global.ww(message, { ...this.loggingInfo, ...data});
        wi = (message: string, data?: any) => global.wi(message, { ...this.loggingInfo, ...data});
        wd = (message: string, data?: any) => global.wd(message, { ...this.loggingInfo, ...data});
    }
}

export interface MyServer extends LoggingInstance {}
@DILoggingInstance
export class MyServer extends ExpressApp {
    loggingInfo = {
        birth: Date.now() // This isn't appearing in the logs either!
    };
}

Output:

MyServer = MyServer_1 = __decorate([
    DILoggingInstance_1.DILoggingInstance
], MyServer);

Conclusion

It looks like I can augment classes with functions via either decorators (DI?) or applying Mixins. However, for augmenting a class with properties, the decorator method is the only one that works without the extra effort of custom implementation (namely: experimentally improving applyMixins()).

Post-script: Why mixins via applyMixins only alter class instance functions

I altered applyMixins to add a log statement:

export function applyMixins(derivedCtor: any, baseCtors: any[]): void {
    baseCtors.forEach(baseCtor => {
        Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => {
            console.log(`OWNPROP: ${name}`);
            derivedCtor.prototype[name] = baseCtor.prototype[name];
        });
    });
}

The output shows that it loggingInfo is evidently not an 'own property' of MyServer:

OWNPROP: constructor
OWNPROP: we
OWNPROP: ww
OWNPROP: wi
OWNPROP: wd

Class instance functions are successfully augmented because they're prototypical, but loggingInfo isn't, as it's an instance property (which is declared in the constructor rather than on the prototype).

When I'd been expecting mixins to augment class properties, I was probably just misinterpreting this article that gave examples that appeared to look like what I was trying to achieve. Of note, the official TypeScript handbook example does not show mixing-in instance properties.

No matter; I'll go with decorators/DI for this purpose, then. If I ever purely need to augment class prototypes and don't want to activate experimentalDecorators, then I'll keep Mixins in mind.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment