Skip to content

A Custom Transformer for Typescript that enables compile-time Dependency Injection

License

Notifications You must be signed in to change notification settings

wessberg/DI-compiler

Repository files navigation

Logo

A Custom Transformer for Typescript that enables compile-time Dependency Injection

Downloads per month NPM version Dependencies Contributors code style: prettier License: MIT Support on Patreon

Description

This library enables you to use the DI library by providing several ways to transform your source code into a representation that it expects. You can use it as a Node.js loader, as an API, and even as a Custom Transformer for Typescript.

Integration with popular tools such as Webpack, esbuild, Rollup, or something else is easy, and this README provides several examples of ways it can be achieved.

It is optimized for performance, but how fast it can go depends on your setup. Please see the Optimization section for details on how to tweak DI-Compiler so that it works most efficiently.

Features

  • Really lightweight
  • Really fast
  • Low-level implementation that can be used as the foundation for other tools such as Loaders, Plugins, and others.
  • It doesn't ask you to reflect metadata or to annotate your classes with decorators. "It just works".
  • Works without a TypeScript program, so you can use it with tools like babel, esbuild, and SWC for the best possible performance.

Backers

Patreon

Patrons on Patreon

Table of Contents

Install

npm

$ npm install @wessberg/di-compiler

Yarn

$ yarn add @wessberg/di-compiler

pnpm

$ pnpm add @wessberg/di-compiler

Peer Dependencies

@wessberg/di-compiler depends on typescript, so you need to manually install this as well.

You may also need to install pirates depending on the features you are going to use. Refer to the documentation for the specific cases where it may be relevant.

Usage

There are multiple ways you can use DI-compiler, depending on your setup:

Usage as an API

The simplest possible way to use DI-Compiler is with its transform function:

import {transform} from "@wessberg/di-compiler";
const {code} = transform(`\
	import {DIContainer} from "@wessberg/di";
	const container = new DIContainer();
	class Foo {}
	container.registerSingleton<Foo>();
`);

In this example, the compiler knows that container is an instance of DIContainer based on the source text. However, you may be importing an instance of DIContainer from another file, in which case the compiler may not be able to statically infer that an identifier is an instance of DIContainer. For example:

transform(`\
	import {container} from "./services";
	// There may not be a way to statically determine whether or
	// not \`container\` is an instance of DIContainer at this point
	container.get<Foo>();
`);

To help the compiler out, and to improve performance, you can pass in one or more identifiers in the source text that should be considered instances of DIContainer:

transform(
	`\
	import {container} from "./services";
	container.get<Foo>();
`,
	{
		// Treat identifiers named `container` as instances of DIContainer
		identifier: "container"
	}
);

If you want a source map to be generated, make sure to pass that option in as a TypeScript CompilerOption:

const {code, map} = transform(`...`, {
	compilerOptions: {
		sourceMap: true
	}
});

You can pass in a cache to use as an option. This must be a data structure that conforms to that of a standard JavaScript Map data structure:

import {transform, type TransformResult} from "@wessberg/di-compiler";
const cache = new Map<string, TransformResult>();

transform(`...`, {
	cache
});

Usage as a Node.js loader

A very convenient way to use DI-Compiler is as a loader directly with Node.js.

If your codebase is based on native ESM, and if you use Node.js v.18.6.0 or newer, pass it as a loader via the command line

node --import @wessberg/di-compiler/loader

This is not enough on its own to teach Node.js to understand TypeScript syntax, so you'll still need to couple it with a loader like ts-node, tsx or esm-loader.

For example, here's how to use it with the native ESM loader for ts-node:

node --import @wessberg/di-compiler/loader --import ts-node/esm

And, here's how to use it with tsx:

node --import @wessberg/di-compiler/loader --import tsx

Finally, here's how you can use it with esm-loader:

node --import @wessberg/di-compiler/loader --import @esbuild-kit/esm-loader

Alternatively, if you don't use ESM in your project, or if you're running an older version of Node.js, DI-Compiler can be used as a loader too. For example, here's how to use it in combination with ts-node in a CommonJS project:

node -r @wessberg/di-compiler/loader -r ts-node

In all of the above configurations, for both ESM and CommonJS loaders, there is no TypeScript Program context, nor is there a Type checker, so DI-Compiler will attempt to determinate programmatically whether or not the identifiers across your files reference instances of DIContainer or not, by performing partial evaluation on compile time. Please see the Optimization section for details on how this process can be optimized.

Loader SourceMaps

By default, SourceMaps will be generated and inlined inside the loaded modules if the sourceMap option is true inside the resolved tsconfig.

Loader caching

By default, DI-Compiler maintains a disk cache of transformation results from previously evaluated files. That means that successive loads of the same files will be extremely fast.

Customizing DI-Compiler when used as a loader

You can pass in a few options to DI-Compiler via command line options:

Environment Variable Description
DI_COMPILER_TSCONFIG_PATH The path to the tsconfig.json file to use
DI_COMPILER_IDENTIFIER A comma-separated list of identifiers that should be considered instances of DIContainer when transforming the source files
DI_COMPILER_DISABLE_CACHE If set, no disk caching will be used.

Alternatively, you can add a di property to your tsconfig where you can customize its behavior without setting environment variables:

// Inside your tsconfig.json
{
	"di": {
		"identifier": "container",
		"disableCache": false
	},
	"compilerOptions": {
		// ...
	}
}

Usage as a TypeScript Custom Transformer

You can use the DI-Compiler anywhere TypeScript Custom Transformers can be used. One advantage of this approach is that you often have access to a TypeScript Program, which can be leveraged by the DI-Compiler to fully understand the structure of your program and specifically the type hierarchy and whether or not an identifier is an instance of DIContainer, for example.

A few examples of ways to use DI-Compiler as a Custom Transformer include:

Usage with TypeScript's Compiler APIs

There's several ways to do this, but here's a simple example:

import {createProgram, getDefaultCompilerOptions, createCompilerHost} from "typescript";
import {di} from "@wessberg/di-compiler";

const compilerOptions = getDefaultCompilerOptions();
const compilerHost = createCompilerHost(compilerOptions);

// Create a Typescript program
const program = createProgram(["my-file-1.ts", "my-file-2.ts"], compilerOptions, compilerHost);

// Transform the SourceFiles within the program, and pass them through the 'di' transformer
program.emit(undefined, undefined, undefined, undefined, di({program}));

Usage with ts-nodes programmatic API

ts-node can also be used programmatically. Here's an example of how you may combine it with DI-Compiler:

import {di} from "@wessberg/di-compiler";

require("ts-node").register({
	transformers: program => di({program})
});

Usage with Rollup

There are a few TypeScript plugins for Rollup that support Custom Transformers, and DI-Compiler can be easily integrated with them.

Usage with @rollup/plugin-typescript

Here's how you may integrate DI-Compiler with @rollup/plugin-typescript:

import ts from "@rollup/plugin-typescript";
import {di} from "@wessberg/di-compiler";

export default {
	input: "...",
	output: [
		/* ... */
	],
	plugins: [
		ts({
			transformers: program => di({program})
		})
	]
};

Usage with Webpack

There are two popular TypeScript loaders for Webpack that support Custom Transformers, and you can use DI-Compiler with both of them:

Usage with awesome-typescript-loader

Here's how it can be used with awesome-typescript-loader:

import {di} from "@wessberg/di-compiler";
const config = {
	// ...
	module: {
		rules: [
			{
				// Match .mjs, .js, .jsx, and .tsx files
				test: /(\.mjs)|(\.[jt]sx?)$/,
				loader: "awesome-typescript-loader",
				options: {
					// ...
					getCustomTransformers: program => di({program})
				}
			}
		]
	}
	// ...
};

Usage with ts-loader

ts-loader can be used in exactly the same way as awesome-typescript-loader:

import {di} from "@wessberg/di";
const config = {
	// ...
	module: {
		rules: [
			{
				// Match .mjs, .js, .jsx, and .tsx files
				test: /(\.mjs)|(\.[jt]sx?)$/,
				loader: "ts-loader",
				options: {
					// ...
					getCustomTransformers: program => di({program})
				}
			}
		]
	}
	// ...
};

Options

The transform function, as well as the di Custom Transformer takes the same set of base options to configure tehir behavior. All of these options are optional:

Option Type Description
program TypeScript Program A full TypeScript program. When given, a Typechecker will be used to understand the type hierarchy of the application and to determine whether or not identifiers are instances of DIContainer.
typescript TypeScript module If given, the TypeScript version to use internally for all operations.
identifier string[] or string One or more identifiers in the source text that should be considered instances of DIContainer. Note: If a Program is passed, this option will be ignored.
compilerOptions TypeScript CompilerOptions A TypeScript Compiler Options record. Note: If a Program is passed, this option will be ignored.

Optimization

Even though DI-Compiler is built for speed, there are ways you can speed it up significantly.

Optimization 1: Activate preserveValueImports in your tsconfig CompilerOptions

By default, TypeScript will discard imported bindings of value types that are unused. This means that the following example:

import {Foo} from "./foo";
container.registerSingleton<Foo>();

Would actually compile into code that would crash on runtime:

// Error: Foo is not defined
container.registerSingleton(undefined, {identifier: "Foo", implementation: Foo});

To work around this, DI-Compiler has to track the imports of the files, and add them back in after transpilation, which comes at a cost.

You can optimize this by activating the preserveValueImports option in your tsconfig:

{
	"compilerOptions": {
		"preserveValueImports": true
	}
}

By doing that, you instruct TypeScript to leave unused value imports be. DI-Compiler will recognize this and opt out of all the internal logic for adding imported bindings back in.

Optimization 2: Pass in one or more identifiers to consider instances of DIContainer instead of relying on partial evaluation

Note: This optimization is irrelevant if a Typescript Program is passed to DI-Compiler.

As described here, it may not always be possible to statically infer whether or not an identifier is in fact an instance of DIContainer when DI-Compiler does not have access to a Typechecker. Or, it may simply be slow, in case a lot of Nodes have to be visited in order to determine it.

To make it more robust and much faster simultaneously, pass in one or more identifiers as the identifier option that should be considered instances of DIContainer:

import {di, transform} from "@wessberg/di-compiler";

// Example when using the transform function
transform(
	`\
	import {container} from "./services";
	container.get<Foo>();
`,
	{
		// Treat identifiers named `container` as instances of DIContainer
		identifier: "container"
	}
);

See this section for details on how to pass the option when DI-Compiler is used as a loader.

Contributing

Do you want to contribute? Awesome! Please follow these recommendations.

FAQ

DI-Compiler doesn't correctly update all my calls to the DIContainer methods

If you pass a Program to DI-Compiler (such as you typically do when you use it as a Custom Transformer), this means that the Typechecker wasn't able to determine that one or more identifiers in your code was in fact instances of DIContainer. Please verify that TypeScript correctly tracks the type of the objects on which you invoke the relevant DIContainer methods.

If you don't pass a Program to DI-Compiler, then you're relying on DI-Compiler being able to statically infer whether or not identifiers are instances of DIContainer without having access to multiple files inside your application. This will very often lead to problems if you reference an instance of DIContainer from another file inside your application. To fix it, pass one or more identifiers that should be considered instances of DIContainer as an option. Please see this section for details on how you can do that.

How does it work, exactly?

First, classes that are discovered as part of your Typescript program/bundle will be parsed for their constructor argument types and positions. Then, instances of the DIContainer will be discovered and their expressions will be upgraded. For example, an expression such as:

import {DIContainer} from "@wessberg/di";
import {MyInterface} from "./my-interface.js";
import {MyImplementation} from "./my-implementation.js";

const container = new DIContainer();
container.registerSingleton<MyInterface, MyImplementation>();

Will be compiled into:

// ...
container.registerSingleton(undefined, {
	identifier: `MyInterface`,
	implementation: MyImplementation
});

License

MIT ©