npm package: @mikrokit/di
Description: A lightweight TypeScript dependency injection container that uses only strip-tipes compliant methodologies and does not rely on reflect-metadata
Author: Artem Tarasenko
Latest version: 0.5.2

# TypeScript Dependency Injection Library

A lightweight TypeScript dependency injection container that uses only strip-tipes compliant methodologies and does not rely on reflect-metadata

## Features

- Symbolic tokens for type-safe dependency resolution
- Support for singleton and transient dependency scopes
- Module system for organizing and importing provider groups
- Circular dependency detection
- Fully type-safe API

## Installation

```bash
npm install @mikrokit/di
# or
yarn add @mikrokit/di
# or
pnpm add @mikrokit/di
```

# Getting started

## 1. Create a container

Container is a thing that will manage you dependencies and give you a way to inject them

```ts
import { createContainer } from '@mikrokit/di'

const container = createContainer()
```

## 2. Define a provider

The simplest entity of dependency injection is a provider.

```ts
import { defineProvider } from '@mikrokit/di'

export const Logger = defineProvider(() => {
  const info = (message: string) => {
    console.log(`[INFO]: ${message}`)
  }

  const error = (message: string) => {
    console.error(`[ERROR]: ${message}`)
  }

  return {
    info,
    error,
  }
})
```

## 3. Provide a provider

In order for `Logger` to be accessible through `container` we will need to provide it to the container.

```ts
import { createContainer } from '@mikrokit/di'
import { Logger } from './logger'

const container = createContainer().provide(Logger)
```

## 4. Inject a provider

In order to instantiate and get the provider you provided, we can use `.inject`. Warning: this call **always returns promise**

```ts
// ...

await container.inject(Logger)
```

## 5. Injecting a provider into another provider

That's what we call DI. Let's define a different provider `Fetcher` for this. In order to inject `Logger` into `Fetcher` we will need to call `injector.inject` with the provider definition as a parameter. From this example we can actually see why `Logger` and `Fetcher` are named in PascalCase instead of camelCase - because when you will be referencing them to inject, you will probably use the camelCase version of name for the exact instance of the provider

```ts
import { defineProvider } from '@mikrokit/di'
import { Logger } from './logger'

export const Fetcher = defineProvider(async (injector) => {
  const logger = await injector.inject(Logger)

  const fetchAndLogData = async (url: string) => {
    try {
      const response = await fetch(url)
      const data = await response.text()
      logger.info(data)
    } catch (e) {
      logger.error(`${e}`)
    }
  }

  return {
    fetchAndLogData,
  }
})
```

## 6. Getting the `Fetcher`

For this, we will need to provide both `Logger` and `Fetcher` into our container and just inject the `Fetcher`.

\*NOTE: the order of `.provide` calls in container doesn't matter (at least for single providers)

```ts
import { createContainer } from '@mikrokit/di'
import { Logger } from './logger'
import { Fetcher } from './fetcher'

const container = createContainer().provide(Logger).provide(Fetcher)

const fetcher = await container.inject(Fetcher)
```

# Circular dependencies

Though it is generally a bad practice that code has any circular dependencies, there are cases when there is no other way to overcome this (for example, in chat-bot development). For this matter, there is a mechanism of "lazy" dependency injection.

To show an example, let's use existing `Logger` and `Fetcher` and extend `Logger` to send error logs over HTTP:

```ts
import { defineProvider } from '@mikrokit/di'
import { Logger } from './logger'

export const Fetcher = defineProvider(async (injector) => {
  const logger = await injector.inject(Logger)

  const postData = async (url: string, data: any) => {
    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(data),
      })
    } catch (e) {
      logger.error(`${e}`)
    }
  }

  const fetchAndLogData = async (url: string) => {
    try {
      const response = await fetch(url)
      const data = await response.text()
      logger.info(data)
    } catch (e) {
      logger.error(`${e}`)
    }
  }

  return {
    postData,
    fetchAndLogData,
  }
})
```

```ts
import { defineProvider } from '@mikrokit/di'

export const Logger = defineProvider((injector) => {
  const fetcher = injector.injectLazy(Fetcher)

  const info = (message: string) => {
    const fullMessage = `[INFO]: ${message}`

    console.log(fullMessage)
    fetcher.value.postData('https://myserver.com/logs', fullMessage)
  }

  const error = (message: string) => {
    const fullMessage = `[ERROR]: ${message}`

    console.log(fullMessage)
    fetcher.value.postData('https://myserver.com/logs', fullMessage)
  }

  return {
    info,
    error,
  }
})
```

In this case, we are using `injectLazy` API to get `Fetcher` inside `Logger` though `Fetcher` is already dependent on `Logger`. When using this API, `Fetcher` instantiation happens right after the `Logger` is fully instanciated.

The big limitation of lazy-injected providers is that it is only possible to use lazy-injected providers after the provider has been fully built, as before that they are not instanciated at all.

The injected provider is available via `.value` getter of the object returned by `injectLazy`. As when using `injectLazy` the injection doesn't happen before the provider is instanciated, it isn't needed to await `injectLazy` call.

# Basic concepts

## Provider

Provider is... well it's basically anything. Anything that is provided to injection container or module is a provider.

Any provider is defined as a factory function that accepts `Injector`, that is injection context that allows injecting other providers.

Static values are defined as factories too, which adds a little overhead but don't add too much noize in codebase of the library and the API

The providers are provided into modules/containers using `Provider Token`, which acts like an identifier for provider without declaring its specific implementation.

When you use `defineProvider` the token is generated automatically and is provided through the factory. If you don't want to have attached token automatically, you can use `defineProviderFactory`, which is only a type helper that doesn't do anything to the factory you provide to it.

```ts
// Both a factory and a token
const MyProvider = defineProvider(() => 'test')

// Only a function
const myProviderFactory = defineProviderFactory(() => 'test')
```

For the cases when you already have the token that you will be defining a provider for (for example, this is the case for group providers), `defineProvider` can accept the token as a 2-nd parameter into it.

### Asynchronous providers

If there is some asynchronous action that should be taken on provider instantiation, the factory can also be async.

```ts
const Redis = defineProvider(async () => {
  const redis = new RedisConnection()

  await redis.connect()

  return redis
})
```

This, however, creates a limitation: **all injections are asynchronous**.

## Provider token

Provider token is a typed "pointer" to some provider. It is used as an injection-key

The provider tokens in `@mikrokit/di` are based on javascript symbols. This way, we ensure that each token is completely unique.

There are two types of tokens: **single tokens** and **group tokens**

### Single Tokens

Single token is a token that references only one provider in the injection. It can only be provided once and will fail to provide the second time.

Most of thins in your codebase will probably be referenced by a single-type token, such as services, repositories, configurations etc.

### Group tokens

Group token is a token that can reference 0 or more providers. There is no limit on how many providers you can provide using this token.

Group tokens are great for defining providers that would be used on higher-levels. For example, API handlers or queue system queues.

## Container

Container is a thing that allows you to provide and inject your providers. It is basically an injection container itself.

## Module

Module is a simple part that allows grouping of some providers into one thing, which can then either be:

- Imported in another module
- Imported in container

This allows creating cleaner definition files

## Injector

Injector is passed inside your providers and is basically a `Container`, but covered with the `Injector` interface so we don't have situations when providers are provided inside providers (which is not good)

It allows injecting other providers by a token

## Provision scope

Library supports two provision scoped defined in `ProvideScope` enum:

- `SINGLETON` - uses a shared copy of a provider
- `TRANSIENT` - uses a freshly instantiated copy of a provider. _Note: if a provider is injected as transient, it doesn't mean that its sub-dependencies will be injected as transient as well. If provider uses singleton injection internally, then its dependencies will be loaded as singletons_

# Testing

One of the greatest advantages of dependency injection is code being easier to **test**. Example of how it might work:

```typescript
// In your test
const testContainer = createContainer()

// Mock dependencies
testContainer.provide(Logger, defineStaticProvider(/* Mock code */))
testContainer.provide(Fetcher, defineStaticProvider(/* Mock code */))

// Test with mocked dependencies
const fetcher = await testContainer.inject(Fetcher)
```

## License

The MIT License (MIT)

Copyright (c) 2025-present, Artem Tarasenko