Documents injecute 1.x · Migrating from 0.x
Skip to content

Migrating from 0.x to 1.0 ​

injecute 1.0 cleans up the public API. This page lists every breaking change with a before/after example. Work through it top to bottom; most changes are mechanical.

Checklist

  1. Use Node.js ≥ 22.12 and import { DIContainer } from 'injecute' (no default export, ESM only).
  2. Replace IDIContainer in module signatures with ServiceRegistry<{ …what the module needs }>, and consumers' container parameters with ServiceProvider<{ … }>.
  3. Drop construct() for native classes (keep it for ES5-compiled and bound classes).
  4. Replace 'undefined' dependency keys with optional(key) or () => undefined.
  5. Rename allowUnresolved → optional, has(key, false) → has(key, { local: true }), new DIContainer({ parentContainer }) → parent.fork().
  6. Update middlewares to (key, next, { container }) and fork({ skipResolvers }) to fork({ middlewares: false }).
  7. Replace flatten() with fork({ isolated: true }).
  8. Branch on InjecuteError.code instead of error messages; factory errors now arrive wrapped (the original is cause).
  9. await preload(app): it returns a promise now.
  10. Replace ContainerServices<typeof app>['Billing'] (a namespace's container type) with NamespaceServices<typeof app, 'Billing'>.
  11. Pass { replace: true } where a fork, or a namespace callback, registers a key its parent has, and only there: replace: true for a key that nobody has throws.

Module format: ESM only ​

1.0 ships a single ES module build. The CommonJS build is gone.

  • Node.js ≥ 22.12 is required. require('injecute') keeps working through Node's require(esm) support, which 22.12 is the first 22.x release to enable without a flag.
  • Browsers, Deno and Bun import the package directly, including from CDNs such as esm.sh and jsDelivr.
  • Bundlers need no configuration.
ts
// before (CommonJS)
const { DIContainer } = require('injecute');

// after (ESM)
import { DIContainer } from 'injecute';

// still works on Node ≥ 22.12 (require of an ES module)
const { DIContainer } = require('injecute');

The utils object is removed ​

Every helper was already exported by name. The grouped utils object duplicated them and broke the published type declarations for projects with skipLibCheck: false.

ts
// before
import { utils } from 'injecute';
utils.construct(MyClass);

// after
import { construct } from 'injecute';
construct(MyClass);

null and undefined are valid service values ​

0.x treated a factory result of null or undefined as "not registered": get() threw No service registered, and singletons returning null ran their factory again. 1.0 looks services up by key, so any value, including null and undefined, is returned and cached.

ts
const c = new DIContainer().addSingleton('session', () => null, []);
c.get('session'); // 0.x: throws · 1.0: null

Decorating a service with a self dependency ​

A dependency on the key being registered now resolves the previous definition of that key: the replaced one in the same container (with replace: true), or the parent's one in a child. In 0.x a child could not do this at all.

ts
const child = parent
  .fork()
  .addSingleton('logger', (logger) => withPrefix(logger), {
    dependencies: ['logger'],
    replace: true, // required in a fork too, see below
  });

Re-registering an own key without replace: true throws already registered (0.x threw CircularDependencyError when the new registration depended on itself).

A fork needs replace: true to override a parent's key ​

In 0.x a fork could register a key its parent has without replace. Services registered in the parent kept the parent's instance, and nothing said so. 1.0 throws INJECUTE_ALREADY_REGISTERED and names those services; pass { replace: true } when the override is meant for the fork's own services only.

ts
// 0.x
const scope = app.fork().addInstance('logger', requestLogger);
// 1.0
const scope = app
  .fork()
  .addInstance('logger', requestLogger, { replace: true });

When a parent service should use the fork's instance, register that service in the fork too, or use fork({ isolated: true }) to replace it for every service. See Overriding a parent's service in a fork. The same applies to namespace() callbacks, which receive a fork. addAlias() takes { replace: true } now too.

The other way round, replace: true for a key that no container has throws INJECUTE_NOTHING_TO_REPLACE (0.x registered it as a new service). Remove replace: true from first registrations, or find the registration that is missing.

Middlewares run once per get() ​

In 0.x a fork copied its parent's middlewares, and resolving a parent service ran them again at every level, so one get() through two forks ran a middleware three times. 1.0 runs the chain once per get().

Middleware signature: (key, next, context) ​

Middlewares no longer receive the container as this, so arrow functions work. The container and the resolution path are on the third argument. next() continues with the same key; next(otherKey) resolves another key.

ts
// before
container.use(function (key, next) {
  this.get('logger').debug(String(key));
  return next(key);
});

// after
container.use((key, next, { container, path }) => {
  container.get('logger').debug(path.join(' > '));
  return next();
});

Forks inherit their ancestors' middlewares live: a middleware added to a parent after fork() applies to existing forks. fork({ skipResolvers: true }) is now fork({ middlewares: false }). Remove a middleware with unuse(middleware).

flatten() is removed ​

flatten() threw with its default options and, when forced to run, cached the flattened container's singletons in the root container. Use an isolated fork instead: it runs every factory it resolves in the fork, so overrides reach the whole graph and nothing leaks back.

ts
// before
const copy = app.flatten({ onKeyIntersection: (k) => () => undefined });

// after
const copy = app.fork({ isolated: true });

Introspection: getRegistration() replaces getFactory() ​

getFactory(), entryTypeKey, firstResult and the EntryType type are removed. Read registration metadata with getRegistration(key), which returns a read-only RegistrationInfo (kind, dependencies, target, namespace, depth). EntryType is now RegistrationKind.

The replace event payload reports the replaced registration as previous: RegistrationInfo instead of replaced: { callable, type }.

Registration hooks are removed ​

The undocumented beforeResolving, afterResolving and beforeReplaced registration options are gone. Use the produce / replace events or a middleware instead.

Container roles: ServiceProvider, ServiceRegistry, DIContainer ​

1.0 splits the container into three levels. Each piece of code gets only what it needs:

TypeCanGive it to
ServiceProvider<S>resolve (get, has, createResolver, call) and inspect (keys, getRegistration)handlers and other consumers, middleware context, event payloads
ServiceRegistry<S>everything above, plus register (add*, addAlias, namespace, extend, injecute, bind)module / extension functions
DIContainer<S>everything above, plus use/unuse, events, fork, reset, disposethe composition root
  • ServiceRegistry<S, A> keeps the services it was given (S) apart from the ones it added (A), and resolves both. You rarely write A; it shows up in inferred module types, for example ServiceRegistry<{ db: Database }, { invoices: InvoiceRepository }>. A container is a registry that was given nothing: DIContainer<S> is a ServiceRegistry<{}, S>.
  • IDIContainer<S> still exists as a deprecated alias of ServiceRegistry<{}, S>.
  • The chain keeps its type: new DIContainer().addInstance(…).use(…) compiles (0.x lost use() after the first add*).
  • get('SomeNamespace'), getParent() and the middleware/event container are read-only providers.
  • DIContainer<TOwn, TParent> has one type parameter now: DIContainer<S>.

Modules no longer need to be generic. Declare only what the module needs and apply it with extend():

ts
// before
const addBilling = <T extends { db: Database }>(c: IDIContainer<T>) =>
  c.addSingleton('invoices', construct(InvoiceRepository), ['db']);

// after
const addBilling = (c: ServiceRegistry<{ db: Database }>) =>
  c.addSingleton('invoices', InvoiceRepository, ['db']);

app.extend(addBilling); // type error if `db` is not registered

Namespace types ​

A namespace is typed now. get('Billing') returns a ServiceProvider of the namespace's services instead of an untyped container, and NamespaceServices<C, 'Billing'> returns those services.

In the service map, the namespace name holds a Namespace marker (AsyncNamespace in an AsyncDIContainer) instead of a container type. The map keeps every namespaced service once, under its full key, so nested namespaces don't multiply the type and exported containers keep small .d.ts files.

ts
// before: an IDIContainer<Record<ArgumentsKey, any>>
type Billing = ContainerServices<typeof app>['Billing'];

// after
type Billing = NamespaceServices<typeof app, 'Billing'>; // { invoices: InvoiceRepository }
app.get('Billing'); // ServiceProvider<{ invoices: InvoiceRepository }>
app.get('Billing.invoices'); // InvoiceRepository, as before

ServiceType<S, K> is what resolving K from a service map S returns: the registered type, or a provider for a namespace key.

Classes are registered directly ​

Classes are detected and constructed with new; construct() is optional.

ts
// before
app.addSingleton('users', construct(UserRepository), ['db']);

// after
app.addSingleton('users', UserRepository, ['db']);

Keep construct() for classes compiled to ES5 (Babel for old browsers, TypeScript target: es5) and bound classes: they look like plain functions and cannot be detected.

Optional dependencies: optional(key) ​

The magic 'undefined' dependency key is gone (and 'undefined' is an ordinary key now). Use optional(key) for "the service if it is registered", or a function dependency for a fixed value.

ts
// before
app.addSingleton('mailer', construct(Mailer), ['undefined']);

// after
app.addSingleton('mailer', Mailer, [optional('transport')]);
app.addSingleton('mailer', Mailer, [() => undefined]);

Renamed options and removed constructor argument ​

0.x1.0
get(key, { allowUnresolved: true })get(key, { optional: true })
has(key, false)has(key, { local: true })
new DIContainer({ parentContainer: parent })parent.fork()
createProxyAccessor(c, { allowUnresolved })createProxyAccessor(c, { optional })
import DIContainer from 'injecute'import { DIContainer } from 'injecute'
ArgumentsKeyServiceKey (deprecated alias kept)
DependenciesToTypes<Keys, S>ResolveDependencies<Keys, S> (deprecated alias kept)
Events<C>ContainerEvents

createProxyAccessor() objects now support in, Object.keys() and spreading; writes throw The proxy accessor is read-only.

Removed type exports ​

These helper types are gone; most were internal or unused: Callable, Func, Constructor, CallableResult, ValueOf, Empty, Flatten, Merge, KeyForValueOfType, ValuesOfType, ArgumentsTypes, Dependencies, TypesToKeys, PromisedProperties, CallableOf, ArgumentsResolver, InjecuteOptions, WithNamespace, MapOf, ResolversMapKeys, Resolve, Resolver, IDIContainerExtension, ContainerOwnServices, ContainerParentServices, DIContainerConstructorArguments, OptionalDependencySkipKey and optionalDependencySkipKey.

Use Factory<D, S>, Produced<F>, ResolveDependencies<D, S>, ContainerServices<C> and the option types (SingletonOptions, InstanceOptions, ForkOptions, …) instead. A module function is simply (c: ServiceRegistry<{ … }>) => ….

Errors: InjecuteError with codes ​

Every error injecute throws is an InjecuteError with a stable code (for example INJECUTE_NOT_REGISTERED), the resolution path, a docs link, and a message that says how to fix it. CircularDependencyError is a subclass. Branch on code instead of matching message text; messages changed and may change again.

ts
try {
  app.get('users');
} catch (error) {
  if (
    error instanceof InjecuteError &&
    error.code === 'INJECUTE_NOT_REGISTERED'
  ) {
    // error.path: ['users', 'db'], error.docs: https://masyaka.github.io/injecute/errors/not-registered
  }
}

Errors thrown by your factories, and rejections of the promises they return, are wrapped in INJECUTE_RESOLUTION_FAILED with the original error as cause, so the message shows which service needed it. Check error.cause if you relied on catching your own error types from get(). dispose() failures reject with INJECUTE_DISPOSE_FAILED (the individual errors are in cause, an AggregateError).

preload() returns a promise ​

preload() now returns a promise that settles once async services (factories that return promises) are created, and rejects with the first failure. In 0.x a failing async service became an unhandled rejection. Synchronous failures still throw immediately.

ts
// before
preload(app);

// after
await preload(app);

Async services ​

A singleton whose factory rejected is no longer cached: the next get() calls the factory again. dispose() releases the resolved value of async singletons.

AsyncDIContainer was never exported in 0.x (an import { AsyncDIContainer } from 'injecute' failed). In 1.0 it is public, with its own module and consumer types; see Async services.

Released under the MIT License.