Skip to content

zheksoon/context-tree

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

9 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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:

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.

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<IConfig>("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:

const RootContext = new Context<RootModel>('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:

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

const ConfigContext = new Context<IConfigModel>("ConfigContext");

// pick only option1 and option2 from IConfigModel
type IPartialConfigModel = Pick<IConfigModel, "option1" | "option2">;

// define a partial context derived from the ConfigContext
const PartialConfigContext = ConfigContext.partial<IPartialConfigModel>(
  "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.

const Context1 = new Context<number>("Context1");
const Context2 = new Context<string>("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:

const Context1 = new Context<number>("Context1");
const Context2 = new Context<string>("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:

interface IContext {
  context: IContext | IContext[] | null | undefined;
  contextType?: Context<any>;
  contextResolvers?: ContextResolvers;
}

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

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

Context

new Context<T>(name: string): Context<T>

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

contextInstance.partial<T>(name: string): Context<T>

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<T>(resolver: () => T): ContextResolver<T>

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<ContextResolver<any>>): ContextResolvers

Define a list of resolvers for a model.

contextInstance.resolve<T>(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<T>(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<any> | 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