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
.
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.
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.
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. */
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. */
In these examples, we'll go with interface declaration merging as our strategy for declaring conformance to the interface.
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 differentapplyMixins()
implementation that updates static methods by iterating the keys of bothMyServer
and the mixin'sbaseCtors
. - COULD POTENTIALLY alter class fields:
this.someClassField = valueFromConstructor
, but would need a differentapplyMixins()
implementation. It could copyMyServer.prototype.constructor
to a variable calledMyServer.prototype.originalConstructor
and then runfunction 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]);
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);
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()
).
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.