A Custom Transformer for Typescript that enables compile-time Dependency Injection
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.
- 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.
Trent Raymond | scrubtheweb |
- Description
- Backers
- Table of Contents
- Install
- Usage
- Usage as an API
- Usage as a Node.js loader
- Loader SourceMaps
- Loader caching
- Customizing DI-Compiler when used as a loader
- Usage as a TypeScript Custom Transformer
- Usage with TypeScript's Compiler APIs
- Usage with ts-nodes programmatic API
- Usage with ttypescript
- Usage with Rollup
- Usage with Webpack
- Usage with ava
- Options
- Optimization
- Contributing
- Maintainers
- FAQ
- License
$ npm install @wessberg/di-compiler
$ yarn add @wessberg/di-compiler
$ pnpm add @wessberg/di-compiler
@wessberg/di-compiler
depends on typescript
, so you need to manually install this as well.
There are multiple ways you can use DI-compiler, depending on your setup:
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
});
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 --loader @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 --loader @wessberg/di-compiler/loader --loader ts-node/esm
And, here's how to use it with tsx
:
node --loader @wessberg/di-compiler/loader --loader tsx
Finally, here's how you can use it with esm-loader
:
node --loader @wessberg/di-compiler/loader --loader @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 ofDIContainer
or not, by performing partial evaluation on compile time. Please see the Optimization section for details on how this process can be optimized.
A convenience loader is exposed that combines ts-node
and DI-Compiler in a single loader for CommonJS projects, which also exposes a TypeScript Program to DI-Compiler, which is the most robust way to use it as a loader, at the expense of slower performance:
node -r @wessberg/di-compiler/ts-node-loader
Note: You must install ts-node yourself for this configuration to work, as it is not listed as a dependency of DI-Compiler.
By default, SourceMaps will be generated and inlined inside the loaded modules if the sourceMap
option is true
inside the resolved tsconfig.
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.
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": {
// ...
}
}
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:
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}));
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})
});
To use DI-compiler with ttypescript
, create a file that wraps the invocation of di
:
import type { Program } from 'typescript'
import { di } from "@wessberg/di-compiler";
const transformer = (program: Program) => di({ program })
export default transformer
Then add a record to the plugins
array of your tsconfig.json
that maps a key named transform
to the relative path to the file you just created:
{
"compilerOptions": {
"plugins": [{"transform": "path/to/transformer.ts"}]
}
}
There are a few TypeScript plugins for Rollup that support Custom Transformers, and DI-Compiler can be easily integrated with them:
To use DI-Compiler with rollup-plugin-ts, all you have to do is pass it to the list of transformers given as a plugin option:
import ts from "rollup-plugin-ts";
import {di} from "@wessberg/di-compiler";
export default {
input: "...",
output: [
/* ... */
],
plugins: [
ts({
transformers: [di]
})
]
};
Here's how you may integrate DI-Compiler with rollup-plugin-typescript2:
import ts from "rollup-plugin-typescript2";
import {di} from "@wessberg/di-compiler";
export default {
input: "...",
output: [
/* ... */
],
plugins: [
ts({
transformers: [service => di({program: service.getProgram()})]
})
]
};
There are two popular TypeScript loaders for Webpack that support Custom Transformers, and you can use DI-Compiler with both of them:
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})
}
}
]
}
// ...
};
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})
}
}
]
}
// ...
};
You can also use DI-compiler with the ava
test runner by using DI-Compiler as a loader.
See this section for more details on how to configure it.
For a CommonJS project, you can use the require
property in the ava
configuration. For example:
{
"ava": {
// Other options...
"extensions": ["ts"],
"require": [
"@wessberg/di-compiler/loader",
// For teaching Node.js about TypeScript specific syntax and extensions
"ts-node"
]
}
}
Whereas for an ESM project, the syntax is a little different:
{
"ava": {
// Other options...
"extensions": {
"ts": "module"
},
"nodeArguments": [
"--loader=@wessberg/di-compiler/loader",
// For teaching Node.js about TypeScript specific syntax and extensions.
"--loader=ts-node/esm"
]
}
}
Note, we use ts-node in this example, but we could have used other tools like tsx or esm-loader just as well.
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. |
Even though DI-Compiler is built for speed, there are ways you can speed it up significantly.
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.
Do you want to contribute? Awesome! Please follow these recommendations.
Frederik Wessberg Twitter: @FredWessberg Github: @wessberg Lead Developer |
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.
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
});
MIT ยฉ Frederik Wessberg (@FredWessberg) (Website)