VSCode extension dependency injection

Posted on in #vscode

In this post I’ll show how to use dependency injection in your extension via InversifyJS library. Here’s about from official site:

InversifyJS is a lightweight (4KB) inversion of control (IoC) container for TypeScript and JavaScript apps. A IoC container uses a class constructor to identify and inject its dependencies.

Sounds good. Let’s begin. :)

First we need to create our extension. You can read about extension creation in my post about VSCode extension code coverage. I’ve chosen vscode-di name for extension so everywhere later I’ll be using that name. After extension is created, open it in VSCode.

VSCode with extension

After you have extension created it’s required to install InversifyJS itself and also additional package called reflect-metadata. Do it via this console command:

npm install inversify reflect-metadata --save

Here what I got:

+ reflect-metadata@0.1.12
+ inversify@4.13.0

One note here: packages installed as dependencies and not devDependencies. That’s very important thing. If you install those packages as dev dependencies, it will still work fine while you’re developing, but it will crash if your extension will be installed from marketplace.

Next step that’s required is to enable some compilation options in your tsconfig.json file. experimentalDecorators and emitDecoratorMetadata options must be enabled. Open tsconfig.json file and add there required changes. Here how my tsconfig.json file looks like after changes:

{
    "compilerOptions": {
        "module": "commonjs",
        "target": "es6",
        "outDir": "out",
        "lib": ["es6"],
        "sourceMap": true,
        "rootDir": "src",
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true,
        "strict": true,
        "noUnusedLocals": true
    },
    "exclude": [
        "node_modules",
        ".vscode-test"
    ]
}

That’s it with initialization, now let’s proceed with coding.

Let’s start with defining our interfaces. For this tutorial we’ll do very basic stuff just to show how DI works in Inversify.

export interface Command {
    id: string;
    execute(...args: any[]): any;
}

This Command interface will be used for describing commands that we will register in VSCode.

export interface Printer {
    print(message: string): void;
}

This Printer interface defines abstract point of message output. Later we will inject this Printer into our commands.

For this article I put Command interface into commands folder and Printer interface into utils folder.

Also, for correct injection we need symbols. Symbol defines an identifier that will be used later for registering and resolving dependencies. I placed symbols definition in the root of src folder and this file contains this:

const TYPES = {
    Command: Symbol("Command"),
    Printer: Symbol("Printer")
};

export default TYPES;

After all those changes, your src folder should look this way:

.
├── commands
│   ├── command.ts
├── extension.ts
├── test
│   ├── extension.test.ts
│   └── index.ts
├── types.ts
└── utils
    └── printer.ts

Next step will be to define implementations for our interfaces.

We’ll have 2 commands and 1 printer. Commands will be AddCommand and RemoveCommand, and printer will be very simple - the one that displays message in console through console.log call.

Create new file add-command.ts inside commands folder with the following content:

import { injectable, inject } from 'inversify';

import TYPES from '../types';

import { Command } from './command';
import { Printer } from '../utils/printer';

@injectable()
export class AddCommand implements Command {
    constructor(
        @inject(TYPES.Printer) private printer: Printer
    ) {}

    get id() {
        return 'extension.add';
    }

    execute(...args: any[]) {
        this.printer.print('AddCommand');
    }
}

There are two main things to notice here:

@injectable decorator tells that this class will be injected. It’s mandatory decorator for registering class in the DI container.

@inject decorator for parameter tells DI container that it should resolve the type provided and pass it here.

Second command that we’ll implement looks very similar to AddCommand and it’s called RemoveCommand. Create new file remove-command.ts inside commands folder with the following contents:

import { injectable, inject } from 'inversify';

import TYPES from '../types';

import { Command } from './command';
import { Printer } from '../utils/printer';

@injectable()
export class RemoveCommand implements Command {
    constructor(
        @inject(TYPES.Printer) private printer: Printer
    ) {}

    get id() {
        return 'extension.remove';
    }

    execute(...args: any[]) {
        this.printer.print('RemoveCommand');
    }
}

Difference between 2 commands are basically name of the class, id of command and what this command prints via Printer.

Now, let’s implement our printer. Create new file console-printer.ts inside utils folder with the following content:

import { injectable } from 'inversify';
import { Printer } from './printer';

@injectable()
export class ConsolePrinter implements Printer {
    print(message: string): void {
        console.log(message);
    }
}

As you may already noticed, it’s implementation is very simple.

Before we will start setting up our container, let’s create one more thing that will help up with commands registering in VSCode. I called it CommandsManager and placed near all the commands we defined - inside commands folder. Here how it looks like:

import * as vscode from 'vscode';
import { multiInject, injectable } from 'inversify';
import TYPES from '../types';
import { Command } from './command';

@injectable()
export class CommandsManager {
    constructor(
        @multiInject(TYPES.Command) private commands: Command[]
    ) {}

    registerCommands(context: vscode.ExtensionContext) {
        for (const c of this.commands) {
            const cmd = vscode.commands.registerCommand(c.id, c.execute);
            context.subscriptions.push(cmd);
        }
    }
}

While this class is quite small, there is one thing that differs from our command implementations. You should notice @multiInject decorator at the constructor parameter and parameter type is array. @multiInject decorator tells DI container to inject all the entities with specified symbol (TYPES.Command in our case). That basically means that all the implementations of Command interface will be passed here as an array.

Phew, that’s it with implementations, now let’s finally configure our DI container and try to work with it.

If you look through official documentation for Inversify, you’ll see that it recommends putting container into inversify.config.ts file. Let’s stick with this recommendation and create the same file in src folder.

import 'reflect-metadata';

import { Container } from 'inversify';
import TYPES from './types';
import { Printer } from './utils/printer';
import { ConsolePrinter } from './utils/console-printer';
import { AddCommand } from './commands/add-command';
import { Command } from './commands/command';
import { RemoveCommand } from './commands/remove-command';
import { CommandsManager } from './commands/commands-manager';

const container = new Container();
container.bind<Printer>(TYPES.Printer).to(ConsolePrinter);
container.bind<Command>(TYPES.Command).to(AddCommand);
container.bind<Command>(TYPES.Command).to(RemoveCommand);
container.bind<CommandsManager>(TYPES.CommandManager).to(CommandsManager);

export default container;

If you previously worked with any DI container, this will look very familiar to you. Still, one important thing here is the first line. Without it, nothing would work, because this library should be imported globally once.

So, now we have our entities, symbols and set up our container. Let’s finally do something useful and register our commands so that they will be working.

Open file extension.ts and inside activate method write the following:

const cmdManager = container.get<CommandsManager>(TYPES.CommandManager);
cmdManager.registerCommands(context);

Also, don’t forget to tell in package.json file that your extension contributes commands. Here how mine contributes section looks like:

"contributes": {
    "commands": [
        {
            "command": "extension.add",
            "title": "DI: Add"
        },
        {
            "command": "extension.remove",
            "title": "DI: Remove"
        }
    ]
}

That’s basically it. You can now launch your extension and try to invoke command from command palette.

I tried to launch add command and it failed… :(

Running the contributed command:'extension.add' failed.

You might be wondering “why did that happen???”. Well, if you will try to launch your extension under debug and stop at breakpoint inside any command’s execute method, you’ll notice that this is undefined.

Extension run inside debugger

Explanation for this is quite simple: this context is not set to your object when execute method is launched. You can fix this by providing correct this context when registering your command inside VSCode. Open your CommandManager and find line with registerCommand invocation. This function accepts third parameter thisArg?: any. Since it’s optional and we didn’t provide it, this when execute method is called by VSCode is undefined.

Change this line into this:

const cmd = vscode.commands.registerCommand(c.id, c.execute, c);

and that will setup correct this context during method execution.

You can try to launch extension once again and you’ll notice that everything works fine and your commands displaying messages in debug console of VSCode.

Debug console output

Conclusion

Hopefully, you now have a better understanding of how to integrate DI framework into your extension codebase and start using it. During this article we’ve just scratched the surface of what Inversify can do. If you want to know more about Inversify’s features, refer to the documentation. If you have any questions about this article or something isn’t working after you followed all steps, you can drop me a message in Twitter or view the source code of final extension in GitHub.