tsyringe
is a really cool library that does automagical dependency injection for classes, etc.
The entire premise of this library compared to others, is that using the powerful reflection features of TypeScript, they can examine the argument list for a class constructor, and automagically populate the argument list for you, relieving you of the burden of passing things around by yourself.
This is ideal, because in tests, you can simply re-define some "provider" to inject stubs, mocks or other test doubles into the containers, so your classes don't talk directly to the internet, or launch redis missiles, or whatever other whacky side-effects your "normal" implementations might have.
I'm working on a tiny toy app that doesn't use class based syntax though, it rather comprises dozens of tiny small functions, 5-10 lines of virtually stateless things that maybe throw a few keys in Redis, or make a single API request, or whatever.
Here tsyringe can be helpful too, but the docs don't make it super easy.
The trick is to eschew all the fancy decorator syntax and simply use the container
directly, check this out:
// File: di.ts
import "reflect-metadata";
import { container } from "tsyringe";
import { createRedisClient, RedisClient } from "./redisClient";
import env, { Env } from "./env";
import fetch from "node-fetch";
import rp, { RequestPromiseAPI } from "request-promise-native";
// re-export the container, so people must import this file
// and not accidentally get `container' directly from tsyringe
export { container };
export { RedisClient };
container.register<RedisClient>("RedisClient", {
useFactory: (_) => createRedisClient(),
});
export { Env };
container.register<Env>("env", {
useValue: env,
});
export { RequestPromiseAPI };
container.register<RequestPromiseAPI>("rp", {
useValue: rp,
});
export type FetchAPI = typeof fetch;
container.register<FetchAPI>("fetch", {
useValue: fetch,
});
Most these examples use useValue
here, which should be pretty self-explanatory. The useFactory
one could also use a useValue
, but I want to defer connecting to Redis until someone actually uses this item from the container. That makes shared tests, that possibly don't need the (albeit tiny) overhead of connecting to Redis, can avoid doing so.
Check the docs useFactory
receives an instance of the container
, so if I needed to pull Redis config out of the env
, for example, I'd have access to container.resolve<Env>("env")
in that factory function to help me configure my Redis client properly.
Re-exporting types
The lines
import env, { Env } from "./env";
// ...snip...
export { Env };
// ...snip...
export { RequestPromiseAPI };
and others, are just to help keep the imports in files that consume the DI clean, I wouldn't want to have an import referring to my di.ts
for the thing itself, but have to get the type from the 3rd party package or some other file. The type re-exporting was a late addition to this approach, but I think it makes the consumers cleaner.
Using the DI container
From any regular file then, we do this:
import { container, Env, RequestPromiseAPI, RedisClient } from "../di";
import url from "url";
interface AuthenticationTokens {
readonly id_token: string;
readonly access_token: string;
}
const env = container.resolve<Env>("env");
function authHeader(): string {
return Buffer.from(
[env.AWS_COGNITO_APP_ID, env.AWS_COGNITO_APP_SECRET].join(":")
).toString("base64");
}
export async function authenticate({
code,
redirect_uri,
}: AuthenticateParams): Promise<AuthenticationTokens> {
var options = { "... snip ...": "" }
const rp = container.resolve<RequestPromiseAPI>("rp");
const redisClient = container.resolve<RedisClient>("RedisClient");
return await rp(options)
.then((r: any) => {
const { id_token, access_token, refresh_token } = JSON.parse(r);
const key = `access_token_refresh-${access_token}`;
redisClient
.multi()
.set(key, refresh_token)
.expire(key, 30 * 86400) // days × seconds in a day
.exec();
return { id_token, access_token };
});
}
There's quite a lot of code here, but just take away that we're using the DI for three different things in a relatively simple 15 or so lines of code.
Testing
When it comes to testing, you can simply override those things in your test set-up:
import { container, RedisClient, RequestPromiseAPI } from "../di";
import { authenticate } from "./authenticate";
var sinon = require("sinon");
describe("authenticate helpers", () => {
let redisSpy = sinon.spy();
let rpFake = sinon.fake.resolves(JSON.stringify({ hello: "world" }));
beforeEach(() => {
container.register<RedisClient>("RedisClient", { useValue: redisSpy });
container.register<RequestPromiseAPI>("rp", { useValue: rpFake });
});
afterEach(() => {
// Restore the default sandbox here
sinon.restore();
});
it("it calls out to some pre-defined thing", (done) => {
authenticate({
redirect_uri: "htps://www.example.com/",
code: "0xDEADBEEF",
}).then(() => done());
});
});
No exhaustive walk-through of the test code, it should be self explanatory. We're simply forcing the container to provide stubs, fakes and doubles in place of the real implementations.
rp
has a tricky API for this, and I'm rusty with sinon.js, but this seems to work for me so far.
For sure the elegant @injectable
decorators and things for the class-based syntax are fun, but one can certainly still benefit from tools such as tsyringe without needing to adopt a specific way of laying out your classes and functions.
Top comments (1)
I'm not so sure this is a good idea though. It resorts to the Service Locator anti pattern.
I prefer sticking to classes. Not because I like OOP. But because we can use class features, by using constructor injection to manage dependencies. We can still use the "functional" programming style by sticking with simple classes that at most only expose one public function.