Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/zheksoon/context-tree

Simple and flexible hierarchical dependency injection (DI) library for TypeScript and vanilla JS
https://github.com/zheksoon/context-tree

awilix context dependency-injection di di-container di-framework fractal fractal-architecture hierarchical inversifyjs ioc ioc-container react-context tsyringe typescript vanilla-js

Last synced: 6 days ago
JSON representation

Simple and flexible hierarchical dependency injection (DI) library for TypeScript and vanilla JS

Awesome Lists containing this project

README

        


context-tree logo


context-tree


npm version
bundle size
license

**context-tree** is a simple implementation of a hierarchical dependency injection (DI) pattern for building scalable web applications.
It implements the same concept as React's `Context`, but without relying on React.
The pattern allows you to create arbitrary nested applications and sub-applications, simplifying the architecture and maintaining code clarity.

Unlike other DI frameworks, **context-tree** does not require you to define a dependency graph in advance, offering a more flexible approach.
The difference from the other DI frameworks is an inherent hierarchy of injectable entities, called **contexts**.
They align with the hierarchy of the application itself and form a tree like React contexts do.
Like React, you can redefine the context value at any point of the tree, and also add and remove context resolvers dynamically.

**context-tree** is framework-agnostic and can be used with any framework or without a framework at all.
It also does not require any decorators or other magic, so it's easy to understand and debug, and can be used in pure JS projects.
**context-tree** has no dependencies and is very lightweight, and because of the pattern simplicity, it's very fast.

## Defining models

**Context-tree** requires your data models to implement a simple `IContext` interface with only one field required: `context` of type `IContext | IContext[] | null | undefined`.
This field should point to a parent or multiple parent models, or be `null` in case there is no parent model.

Here's a simple example:

```ts
import { IContext } from "context-tree";

class RootModel implements IContext {
// root model does not have context, so it's null
public context = null;

// by convention, all models with context take a parent as the first argument
childModel = new ChildModel(this);
}

class ChildModel implements IContext {
// use TS shortcut to define a field from the constructor arg
// now it points to the parent model
constructor(public context: IContext) {}
}
```

At this point, we have a model tree. Let's define a context and add the resolver to it.

```ts
import { Context, IContext } from "context-tree";

// define some interface for config object
interface IConfigModel {
baseUrl: string;
apiKey: string;
}

// an implementation of the interface
class ConfigModel implements IConfigModel {
baseUrl = "https://.../";
apiKey = "abcdef123";
}

// define a context that carries the type of the config
const ConfigContext = new Context("ConfigContext");

class RootModel implements IContext {
public context = null;

// define resolvers - functions that are called when the context resolves
public contextResolvers = Context.resolvers([
ConfigContext.resolvesTo(() => this.config),
]);

private config = new ConfigModel();

// pass this as the context of the child model
private childModel = new ChildModel(this);
}

class ChildModel implements IContext {
constructor(public context: IContext) {}

async getData() {
// to get the config instance, call `resolve` on the context
// and pass the current model as the first argument
const config = ConfigContext.resolve(this);

// use the config instance
const data = await fetch(`${config.baseUrl}/endpoint`);
}
}
```

In case you want a model itself to be a context, you can use `contextType` field:

```ts
const RootContext = new Context('RootContext');

class RootModel implements IContext {
// no parent context
context = null;

// now RootContext resolves to the model instance
contextType = RootContext;

// define some extra resolvers
contextResolvers = Context.resolvers([
...
]);
}
```

## Partial contexts

Not always full contexts are needed. For example, the config model and its interface can contain dozens of fields, and our model may need only a few of them. When we are writing unit tests for a model, we have to supply a full config context that implements every field from its interface, and that can be cumbersome.

Partial contexts solve this problem by allowing you to define a partial interface from your original context. If the partial context resolves, it will resolve to the closest parent model that implements the partial interface.

Here's an example:

```ts
interface IConfigModel {
baseUrl: string;
option1: string;
option2: string;
option3: string;
}

const ConfigContext = new Context("ConfigContext");

// pick only option1 and option2 from IConfigModel
type IPartialConfigModel = Pick;

// define a partial context derived from the ConfigContext
const PartialConfigContext = ConfigContext.partial(
"PartialConfigContext"
);

// Finds a closest instance of IPartialConfigModel or IConfigModel
PartialConfigContext.resolve(this);
```

### Dynamic context manipulation

Context resolvers can be dynamically added or removed from a model. This might be useful in complex scenarios when contexts are not known in advance.

```ts
const Context1 = new Context("Context1");
const Context2 = new Context("Context2");

class RootModel implements IContext {
// no parent context
context = null;

// define static resolvers
contextResolvers = Context.resolvers([Context1.resolvesTo(() => 1 + 2)]);

// add dynamic resolvers
doSomething() {
this.contextResolvers.addResolver(Context2.resolvesTo(() => "hello"));
}

// remove dynamic resolvers
doSomethingElse() {
this.contextResolvers.removeResolver(Context2);
}
}
```

## Required contexts

Sometimes you want to make sure that a model has all required contexts resolved. For example, you may want to make sure that a model has a config context resolved before it can be used. To do that, you can define a static field `requiredContexts` on a class or class instance:

```ts
const Context1 = new Context("Context1");
const Context2 = new Context("Context2");

class RootModel implements IContext {
// no parent context
context = null;

// define resolvers
contextResolvers = Context.resolvers([Context1.resolvesTo(() => 1 + 2)]);

// define required contexts
// RootModel has no Context2 resolver, so it will throw an error
static requiredContexts = [Context2];
}

// throws an error
Context.checkRequired(new RootModel());
```

# API

## Models

Each model should implement the `IContext` interface:

```ts
interface IContext {
context: IContext | IContext[] | null | undefined;
contextType?: Context;
contextResolvers?: ContextResolvers;
}
```

The usual way to pass the required `context` field is the first argument of the constructor:

```ts
class Model implements IContext {
constructor(public context: IContext) {}
}
```

## Context

### `new Context(name: string): Context`

Creates a new context with the given name. The name is used for debugging purposes.

### `contextInstance.partial(name: string): Context`

Creates a partial context derived from the current context. The partial context can be resolved to the closest parent model that implements the partial interface.

### `Context.resolvesTo(resolver: () => T): ContextResolver`

Create a resolver for the context. The resolver is a function that returns a value of type `T`. The resolver is called when the context is resolved.

### `Context.resolvers(resolvers: Array>): ContextResolvers`

Define a list of resolvers for a model.

### `contextInstance.resolve(model: IContext): T`

Finds the closest context resolver of the type and calls it to resolve the value. If no resolver is found, throws an error.

### `contextInstance.resolveMaybe(model: IContext): T | undefined`

Finds the closest context resolver of the type and calls it to resolve the value. If no resolver is found, returns `undefined`.

### `contextInstance.findResolver(model: IContext): ContextResolver | undefined`

Finds the closest context resolver of the type. If no resolver is found, returns `undefined`.

### `Context.checkRequired(model: IContext): void`

Checks if all required resolvers are defined for the model. If not, throws an error. Required contexts are defined by `requiredContexts` field on a class or class instance.

# Author

Eugene Daragan

# License

MIT