npm package: @gitlab/needle
Description: `@gitlab/needle` is a lightweight dependency injection framework for TypeScript, using ES stage 3 decorators. It provides a type-safe solution for managing dependencies in your TypeScript projects.
Latest version: 2.0.0

# `@gitlab/needle` documentation

`@gitlab/needle` is a lightweight dependency injection framework for TypeScript, using ES stage 3 decorators. It provides a type-safe solution for managing dependencies in your TypeScript projects.

## Key features

- Implements the new ES stage 3 decorators standard (the TS experimental decorators are now [marked as legacy](https://devblogs.microsoft.com/typescript/announcing-typescript-5-0/#differences-with-experimental-legacy-decorators) and even though still supported, it's recommended to use the standard going forward)
- Compatible with the latest TypeScript and esbuild without special build flags
- Fully type-safe, ensuring correct dependency types and constructor arguments

## Basic usage

1. [Define interfaces](#define-interfaces)
1. [Create interface IDs](#create-interface-ids)
1. [Implement and decorate classes](#implement-and-decorate-classes)
1. [Register services with ServiceCollection](#register-services-with-servicecollection)
1. [Retreive services with ServiceProvider](#serviceprovider)
1. [Manage service scopes](#managing-service-scopes)
1. [Initialize container (DEPRECATED)](#initialize-container-deprecated)
1. [Retrieve dependencies (if needed) (DEPRECATED)](#retrieve-dependencies-if-needed-deprecated)

### Define interfaces

```typescript
interface A {
  field: string;
}

interface B {
  hello(): string;
}
```

### Create interface IDs

```typescript
import { createInterfaceId } from '@gitlab/needle';

const A = createInterfaceId<A>('A');
const B = createInterfaceId<B>('B');
```

### Implement and decorate classes

`@gitlab/needle` provides multiple decorator options for defining services that can be registered with the container.

#### @Implements

The `@Implements` decorator identifier which interface(s) a class implements.

```typescript
import { Implements } from '@gitlab/needle';

@Implements(A)
class AImpl implements A {}
```

#### @Service

The `@Service` decorator defines a service and is required by the system to create a service descriptor. We can specify dependencies and lifetime in this decorator.

```typescript
import { Service, ServiceLifetime } from '@gitlab/needle';

@Service({
  dependencies: [B, C],
  lifetime: ServiceLifetime.Singleton,
  autoActivate: false
})
class AImpl implements A {
  constructor(b: B, c: C) {}
}
```

The `lifetime` parameter can be one of:

- `ServiceLifetime.Singleton` - The service is created once and reused for all requests.
- `ServiceLifetime.Transient` - A new instance of the service is created for each time the service is requested.
- `ServiceLifetime.Scoped` - A new instance is created for each scope

The `autoActivate` parameter controls whether the service should be resolved automatically when the container is
built. This is useful for services that act as an entry point, for example, initialization of a websocket listener.

#### @Injectable

The `@Injectable` decorator is a convenience decorator that combines the functionality of `@Implements` and `@Service`. By default, it declares the service to have a lifetime of `Singleton`

```typescript
import { Injectable } from '@gitlab/needle';

@Injectable(A, [])
class AImpl implements A {
  field = 'value';
}

@Injectable(B, [A])
class BImpl implements B {
  #a: A;

  constructor(a: A) {
    this.#a = a;
  }

  hello() {
    return `B.hello says A.field = ${this.#a.field}`;
  }
}
```

### Register Services with `ServiceCollection`

#### Purpose

`ServiceCollection` is responsible for:

- Registering services and their dependencies
- Validating service registration
- Building a `ServiceProvider` that can resolve those services

#### Usage

##### Creating a ServiceCollection

```typescript
import { ServiceCollection } from '@gitlab/needle';

const services = new ServiceCollection();
```

##### Registering Services

`ServiceCollection` provides several ways to register services:

###### Method 1: Adding Classes Decorated with `@Service` or `@Injectable`

```typescript
import { ServiceCollection, Injectable, createInterfaceId } from '@gitlab/needle';

// Define interface and ID
interface Logger {
  log(message: string): void;
}
const Logger = createInterfaceId<Logger>('Logger');

// Implement and decorate class
@Injectable(Logger, [])
class ConsoleLogger implements Logger {
  log(message: string) {
    console.log(message);
  }
}

// Register with ServiceCollection
const services = new ServiceCollection();
services.addClass(ConsoleLogger);
```

###### Method 2: Adding Service Descriptors Directly

For more advanced scenarios, you can create and add service descriptors:

```typescript
import {
  createInstanceDescriptor,
  createConstructorDescriptor,
  ServiceLifetime
} from '@gitlab/needle';

// Add an existing instance
const config = new ConfigurationService();
services.add(
  createInstanceDescriptor({
    instance: config,
    aliases: [Configuration]
  })
);

// Add a constructor-based service
services.add(
  createConstructorDescriptor({
    implementation: DatabaseService,
    aliases: [Database],
    dependencies: [Configuration],
    lifetime: ServiceLifetime.Scoped,
    autoActivate: false
  })
);
```

##### Chaining Registrations

Registration methods return the ServiceCollection instance to support method chaining:

```typescript
services
  .addClass(UserService)
  .addClass(AuthenticationService)
  .add(
    createInstanceDescriptor({
      instance: new ConfigurationService(),
      aliases: [Configuration],
    }),
  );
```

#### Validating the `ServiceCollection`

Before building a ServiceProvider, you can validate the service registrations to ensure there are no issues:

```typescript
const validationResult = services.validate();

if (!validationResult.isValid) {
  console.error('Service validation failed:');
  validationResult.errors.forEach((error) => console.error(error.message));
}
```

The validation checks for:

- Missing dependencies
- Circular dependencies
- Services with multiple implementations where a single implementation is required

#### Building a `ServiceProvider`

After registering all services, you can build a ServiceProvider to start resolving services:

```typescript
// This will validate services and throw an error if validation fails
const serviceProvider = services.build();
```

If you've set `autoActivate: true` for any services, they will be automatically instantiated when the provider is built.

### `ServiceProvider`

#### Purpose

`ServiceProvider` is responsible for:

- Resolving service identifier based on registered service descriptors
- Managing service lifetime (singleton, transient, scoped)
- Resolving dependencies between services

#### Using `ServiceProvider`

##### Getting a Service

To get a single instance of a service:

```typescript
const logger = serviceProvider.getRequiredService(Logger);
logger.log('Hello, world!');
```

This method will:

- Throw `ServiceNotRegisteredError` if no implementation is registered
- Throw `AmbiguousMatchError` if multiple implementations are registered

##### Getting All Services for an Interface

To get all registered implementations of a service:

```typescript
const loggers = serviceProvider.getServices(Logger);
loggers.forEach((logger) => logger.log('Hello from all loggers'));
```

This method does not throw and returns an empty array if no services are registered for the interface.

### Managing Service Scopes

#### Understanding Service Lifetimes

The behavior of services depends on their registered lifetime:

- **Singleton**: One instance for the entire application lifetime
- **Scoped**: One instance per scope
- **Transient**: New instance every time the service is requested

#### Creating and Using Scopes

copes provide isolation for services marked with `ServiceLifetime.Scoped`. They are useful for request-scoped services or isolating services whose lifetime is distinct from the application lifetime.

```typescript
// Root provider from ServiceCollection
const rootProvider = services.build();

// Create a scope
const scope = rootProvider.createScope();

try {
  // Get a scoped service within this scope
  const userService = scope.getRequiredService(UserService);

  // Use the service...
  await userService.processRequest();
} finally {
  // Always dispose the scope when finished to prevent memory leaks
  scope.dispose();
}
```

##### Nested Scopes

You can create nested scopes to establish hierarchy in your service resolution:

```typescript
const rootProvider = services.build();
const parentScope = rootProvider.createScope();

try {
  // Create a child scope
  const childScope = parentScope.createScope();

  try {
    // Use services from the child scope
    const service = childScope.getRequiredService(ScopedService);
    // ...
  } finally {
    childScope.dispose();
  }
} finally {
  parentScope.dispose();
}
```

##### Resource Disposal

Properly disposing scopes is important to prevent memory leaks, especially for scoped services that implement the `Disposable` interface:

```typescript
interface Disposable {
  dispose(): void;
}

@Implements(DataService)
@Service({
  dependencies: [],
  lifetime: ServiceLifetime.Scoped
})
class DatabaseConnection implements DataService, Disposable {
  #connection: Connection;

  constructor() {
    this.#connection = createConnection();
  }

  // Methods...

  dispose() {
    // Clean up resources when the scope is disposed
    this.#connection.close();
  }
}

// Usage
const scope = rootProvider.createScope();
try {
  const db = scope.getRequiredService(DataService);
  // Use database...
} finally {
  // This will call dispose() on all scoped services in this scope
  scope.dispose();
}
```

### Initialize container (DEPRECATED)

```typescript
import { Container } from '@gitlab/needle';

const container = new Container();
container.instantiate(AImpl, BImpl);
```

### Retrieve dependencies (if needed) (DEPRECATED)

```typescript
const b = container.get(B);
console.log(b.hello());
```

## Advanced usage

### Add external dependencies

For dependencies that can't be provided by the container, you need to add them manually. Here's an example of adding an external logger dependency:

```typescript
import { createInterfaceId, brandInstance } from '@gitlab/needle';

interface Logger {
  log(...args: unknown[]): void;
}

const Logger = createInterfaceId<Logger>('Logger');

const customLogger: Logger = {
  log: (...args) => console.log('custom logger', ...args),
};

container.addInstances(brandInstance(Logger, customLogger));
```

#### Example

We had to add the Language Server connection to the container (we get it from the VS Code framework already created). For that we [created an alias](https://gitlab.com/gitlab-org/editor-extensions/gitlab-lsp/blob/91232887debe11b2b8830975c1d9f8748b85707b/src/common/external_interfaces.ts#L5) for the `Connection` called `LsConnection` (so we can create the `InterfaceId`) and [added it to the container](https://gitlab.com/gitlab-org/editor-extensions/gitlab-lsp/blob/91232887debe11b2b8830975c1d9f8748b85707b/src/node/main.ts#L101).

### Collections

When you need to inject all registered instances of a particular interface, you can use collection dependencies:

```typescript
import { createInterfaceId, collection } from '@gitlab/needle';

// Define interface and create its ID
interface Plugin {
  execute(): void;
}
const Plugin = createInterfaceId<Plugin>('Plugin');

// Implement multiple plugins
@Injectable(Plugin, [])
class Plugin1 implements Plugin {
  execute() {
    console.log('Plugin 1');
  }
}

@Injectable(Plugin, [])
class Plugin2 implements Plugin {
  execute() {
    console.log('Plugin 2');
  }
}

// Inject all plugins into a manager
@Injectable(PluginManager, [collection(Plugin)])
class PluginManager {
  constructor(private plugins: Plugin[]) {}

  executeAll() {
    this.plugins.forEach((p) => p.execute());
  }
}

// Initialize
const container = new Container();
container.instantiate(Plugin1, Plugin2, PluginManager);

// Use
const manager = container.get(PluginManager);
manager.executeAll(); // Outputs: "Plugin 1" "Plugin 2"
```

If the same collection is required in multiple places, a collectionId variable can be created:

```typescript
import { createInterfaceId, createCollectionId } from '@gitlab/needle';

// Same Plugin setup as example above

const PluginsCollection = createCollectionId(Plugin);

@Injectable(PluginManager1, [PluginsCollection])
class PluginManager1 {
  constructor(private plugins: Plugin[]) {}
}

@Injectable(PluginManager2, [PluginsCollection])
class PluginManager2 {
  constructor(private plugins: Plugin[]) {}
}
```

## API reference

### `createInterfaceId<T>(id: string): InterfaceId<T>`

Creates a unique runtime identifier for an interface.

### `createCollectionId<T>(interfaceId: InterfaceId<T>): CollectionId<T>`

Creates a runtime identifier for a collection of interfaces.

### `collection<T>(interfaceId: InterfaceId<T>): CollectionId<T>`

Alias of `createCollectionId`, intended for use directly within @Injectable when no intermediate variable is required.

### `@Injectable(id: InterfaceId<I>, dependencies: (InterfaceId<unknown> | CollectionId<unknown>)[])`

Decorator for classes to mark them as injectable and specify their dependencies.

### `Container`

- `instantiate(...classes: Class[]): void`: Initializes the specified classes.
- `addInstances(...instances: BrandedInstance<object>[]): void`: Adds pre-initialized objects to the container.
- `get<T>(id: InterfaceId<T>): T`: Retrieves an instance from the container.

### `brandInstance<T>(id: InterfaceId<T>, instance: T): BrandedInstance<T>`

Brands an instance for use with `container.addInstances()`.

## Best Practices

1. Define interfaces for your dependencies.
1. Use `createInterfaceId` for each interface.
1. Implement classes and decorate them with `@Injectable`.
1. Initialize your container in the application's entry point.
1. Use `container.get()` sparingly, preferring constructor injection.

## Examples in the code

- [`ConfigService`](https://gitlab.com/gitlab-org/editor-extensions/gitlab-lsp/blob/91232887debe11b2b8830975c1d9f8748b85707b/src/common/config_service.ts#L110) (no constructor arguments)
- [`ConnectionService`](https://gitlab.com/gitlab-org/editor-extensions/gitlab-lsp/blob/91232887debe11b2b8830975c1d9f8748b85707b/src/common/connection_service.ts#L50) (many constructor arguments)
- [Instantiating the classes](https://gitlab.com/gitlab-org/editor-extensions/gitlab-lsp/blob/91232887debe11b2b8830975c1d9f8748b85707b/src/node/main.ts#L125)

## Troubleshooting

- Ensure all classes are decorated with `@Injectable`.
- Ensure your `InterfaceId` instances have the correct interface type and string identifier.
- Ensure that all constructor arguments implement interfaces mentioned in the `@Injectable` decorator.

## VS Code snippet

To reduce boilerplate, consider adding this snippet to your VS Code TypeScript snippets:

```json
{
  "New DI Class": {
    "prefix": "diclass",
    "body": [
      "import { Injectable, createInterfaceId } from '@gitlab/needle';",
      "",
      "export interface ${1:InterfaceName} {}",
      "",
      "export const ${1:InterfaceName} = createInterfaceId<${1:InterfaceName}>('${1:InterfaceName}');",
      "",
      "@Injectable(${1:InterfaceName}, [])",
      "export class Default${1:InterfaceName} implements ${1:InterfaceName} {}"
    ],
    "description": "Creates DI framework boilerplate"
  }
}
```

## FAQ

### Why didn't you decorate the constructor parameters?

You can't, because the ES Stage 3 decorators don't support decorating method/function parameters.

- [See stage 1 proposal for parameter decorators](https://docs.google.com/document/d/1Qpkqf_8NzAwfD8LdnqPjXAQ2wwh8BBUGynhn-ZlCWT0/edit#heading=h.t9k9f05noi8w)

### How is this implemented?

- the type definition of `@Injectable` is a dark, write-only type magic
- the `Container` implementation is a straightforward graph traversing and string validation

Learn more in the blog post mentioned in the next section.

### Compatibility

Current implementation works both for legacy experimental decorators and new TypeScript 5 decorators.
If the decorators are used in a legacy environment, you won't get errors explaining that you should only use `Implements` and `Service` decorators on classes.

## Additional resources

- [TypeScript Dependency Injection using ES Decorators](https://blog.viktomas.com/graph/typescript-di-es-decorators/)
- [Decorator types for TypeScript](https://github.com/microsoft/TypeScript/pull/50820)
- [ES Decorators Proposal](https://github.com/tc39/proposal-decorators)