This page documents the 0.4 release candidates of @nexusdi/core. The newest on npm is 0.4.0-rc.1. The documentation for 0.3.2, the current release, is at nexus.js.org. Try the release candidate.
Upgrading to 0.4
NexusDI 0.4 introduces a fundamental shift in how the container works: it moves from a “lazy, optimistic” model to a “static, validated” model. NexusDI now validates the entire module graph before the first instance is ever created, so wiring errors surface at startup.
Because of this shift, the 0.4 API is significantly different from 0.3. This guide provides a sequential path to migrate your project. set(), DynamicModule, symbol tokens, parameter decorators and createChildContainer() have no 0.4 form. Work through the steps in order, and run npx tsc after each one, because it lists the 0.3 code that remains. Each step shows the 0.3 code, which does not compile against 0.4, and the 0.4 code that replaces it.
The 0.4 release candidates publish no codemod, so every step is an edit you make by hand. The changelog of @nexusdi/core lists every change, and the 0.3 support policy says how long 0.3 receives fixes.
1. Installation and Tooling
First, update your dependencies. 0.4 moves optional features (like decorators and testing) into their own packages to keep the core lean. Install from the next tag during the release candidate (Trying the 0.4 Release Candidate):
npm install @nexusdi/core@next
npm install -D @nexusdi/testing@next @nexusdi/errors@next @nexusdi/devtools@nextEach package holds one feature:
@nexusdi/decorators: The decorators of a project that keeps them:@Injectableand@Injectfor providers,@Modulefor modules.@nexusdi/testing: The testing container.@nexusdi/errorsand@nexusdi/devtools: Word every error in full.@nexusdi/node:nodeScopes(), which binds a scope to one Node request.
TypeScript Update
NexusDI 0.4 requires TypeScript 5.4+ and Node 22.12+. It does not read compiler-emitted metadata, so you can remove experimentalDecorators and emitDecoratorMetadata from your tsconfig.json. A project that needs the flag for another library can keep it: static deps and provide() work with the flag on. Only @nexusdi/decorators needs it off, because it uses standard TC39 decorators. See Using NexusDI with experimentalDecorators. NexusDI 0.4 is published as ES modules only, and a CommonJS project on Node 22.12 or later can require() it. For a project with no other use of the flags, the compiler options end up like this:
Not run
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"strict": true
}
}2. Replacing Symbol Tokens
0.3 allowed the use of Symbols or Strings as tokens. 0.4 requires a Token<T> for any value that isn’t a class. A Token<T> carries the type of what it names and a required description, which errors and the graph show. TokenType<T> becomes InjectionToken<T>, which names a Token or a class.
0.3 (Legacy):
Anti-example
const NAV_CHARTS = Symbol('NavCharts');
container.set(NAV_CHARTS, { useValue: charts });0.4 (Current):
import { Nexus, Token, provide } from '@nexusdi/core';
interface INavCharts {
plot(to: string): string;
}
const NAV_CHARTS = new Token<INavCharts>('NavCharts');
await using ship = await Nexus.create([
provide(NAV_CHARTS, {
useValue: { plot: (to: string) => `course to ${to}` },
}),
]);
const course = ship.get(NAV_CHARTS).plot('Kepler-442b'); // -> 'course to Kepler-442b'
console.log(course);
const name = NAV_CHARTS.description; // -> 'NavCharts'
console.log(name);Token<T> provides both the runtime identity and the TypeScript type, making your dependencies type-safe.
Try it: Save the region as tokens.ts and run npx tsx tokens.ts. It prints course to Kepler-442b and NavCharts. Change the token to const NAV_CHARTS = Symbol('NavCharts'); and npx tsc reports TS2769 on the provide( call and on the get call, because a symbol is not an injection token. npx tsx tokens.ts then stops with NEXUS_BLUEPRINT_INVALID, which holds NEXUS_INVALID_TOKEN with the field received=the symbol Symbol(NavCharts). Change the line back to the Token.
3. Moving to static deps
The @Service and @Inject parameter decorators are replaced by a static deps array on the class. This makes dependencies explicit and verifiable. TypeScript checks the tuple against the constructor. @Optional(X) becomes optional(X) in the tuple.
0.3 (Legacy):
Anti-example
@Service()
class QuantumComputer {
constructor(@Inject(REACTOR) private reactor: IReactorCore) {}
}0.4 (Current):
import { Nexus, Token, optional, provide } from '@nexusdi/core';
interface IReactorCore {
readonly output: number;
}
interface ISubspaceLink {
readonly frequency: number;
}
interface IShipComputer {
status(): string;
}
const REACTOR = new Token<IReactorCore>('ReactorCore');
const SUBSPACE_LINK = new Token<ISubspaceLink>('SubspaceLink');
const COMPUTER = new Token<IShipComputer>('ShipComputer');
class FusionReactor implements IReactorCore {
readonly output = 1.21;
}
class QuantumComputer implements IShipComputer {
static deps = [REACTOR, optional(SUBSPACE_LINK)] as const;
constructor(
private readonly reactor: IReactorCore,
private readonly link: ISubspaceLink | undefined,
) {}
status() {
const link = this.link === undefined ? 'no link' : 'linked';
return `Reactor output ${this.reactor.output} GW, ${link}.`;
}
}
await using ship = await Nexus.create([
provide(REACTOR, { useClass: FusionReactor }),
provide(COMPUTER, { useClass: QuantumComputer }),
]);
const status = ship.get(COMPUTER).status(); // -> 'Reactor output 1.21 GW, no link.'
console.log(status);If you prefer decorators, you can use @Injectable({ deps: [...] }) from the @nexusdi/decorators package. See Writing Providers with Decorators.
Try it: Save the region as services.ts and run npx tsx services.ts. It prints Reactor output 1.21 GW, no link. Swap the two entries of static deps and npx tsc reports TS2769 on the provide(COMPUTER, ...) call, because the tuple no longer matches the constructor. Swap them back.
Plain-Field Injection
0.3 also injected a plain field marked @Inject. 0.4 has no plain-field injection:
Anti-example
@Service()
class QuantumComputer {
@Inject(REACTOR) reactor!: IReactorCore;
}Move that dependency into static deps as a constructor parameter. A project that keeps decorators can write @Inject(REACTOR) accessor reactor!: IReactorCore; with @Inject from @nexusdi/decorators, which accepts accessor fields only. The container sets an accessor field after the constructor returns and before onInit.
4. Redefining Modules
DynamicModule and @Module classes are replaced by the defineModule() function. This allows for better type checking and a clearer distinction between the module definition and its configuration.
defineModule({ name, providers, imports, exports }) declares a module. A configurable module names an options token with options, and the importer passes the options with forRoot(value) or forRootAsync({ useFactory, deps }). Mod.config(value) becomes Mod.forRoot(value), and Mod.configAsync(fn) becomes Mod.forRootAsync({ useFactory: fn }).
0.3 (Legacy): used DynamicModule.forRoot() or @Module classes.
Anti-example
@Module({ providers: [SubspaceRelay] })
class Comms extends DynamicModule<CommsOptions> {
protected readonly configToken = COMMS_OPTIONS;
}
container.set(Comms.config({ frequency: 1420 }));0.4 (Current): Use defineModule() and forRoot() / forRootAsync().
import { Nexus, Token, defineModule, provide } from '@nexusdi/core';
interface CommsOptions {
readonly frequency: number;
}
interface ISubspaceLink {
readonly frequency: number;
}
const COMMS_OPTIONS = new Token<CommsOptions>('CommsOptions');
const SUBSPACE_LINK = new Token<ISubspaceLink>('SubspaceLink');
class SubspaceRelay implements ISubspaceLink {
static deps = [COMMS_OPTIONS] as const;
readonly frequency: number;
constructor(options: CommsOptions) {
this.frequency = options.frequency;
}
}
const Comms = defineModule({
name: 'Comms',
options: COMMS_OPTIONS,
providers: [provide(SUBSPACE_LINK, { useClass: SubspaceRelay })],
exports: [SUBSPACE_LINK],
});
await using ship = await Nexus.create(
defineModule({
name: 'Meridian',
imports: [Comms.forRoot({ frequency: 1420 })],
}),
);
const frequency = ship.get(SUBSPACE_LINK).frequency; // -> 1420
console.log(frequency);Try it: Save the region as modules.ts and run npx tsx modules.ts. It prints 1420. Replace Comms.forRoot({ frequency: 1420 }) with Comms.forRootAsync({ useFactory: () => ({ frequency: 1421 }) }) and it prints 1421. Replace it with the bare Comms and Nexus.create rejects with NEXUS_BLUEPRINT_INVALID, which holds NEXUS_MODULE_OPTIONS_MISSING for module=Comms, because the module declares an options token and no importer supplied the options. Put the forRoot call back.
The Changed Meaning of forRoot
The 0.3 static DynamicModule.forRoot(config) took a module config and returned a runtime module class. A 0.4 forRoot(options) takes the options of one configurable module, which is a different meaning under the same name, so check each 0.3 forRoot call site. DynamicModule.forRoot({ providers }) becomes defineModule({ name, providers }).
Module Encapsulation and exports
0.4 enforces module encapsulation, and this is the change most likely to stop a provider from resolving after the upgrade. A module sees its own providers, the exports of the modules it imports and the exports of every global module. 0.3 read no exports, so a 0.3 graph that worked through one flat map fails until each module exports what its importers use:
import { Nexus, Token, defineModule, isNexusError } from '@nexusdi/core';
import { provide } from '@nexusdi/core';
interface IReactorCore {
readonly output: number;
}
interface IShipComputer {
status(): string;
}
const REACTOR = new Token<IReactorCore>('ReactorCore');
const COMPUTER = new Token<IShipComputer>('ShipComputer');
class FusionReactor implements IReactorCore {
readonly output = 1.21;
}
class QuantumComputer implements IShipComputer {
static deps = [REACTOR] as const;
constructor(private readonly reactor: IReactorCore) {}
status() {
return `Reactor output ${this.reactor.output} GW.`;
}
}
// Engineering provides REACTOR but does not export it.
const Engineering = defineModule({
name: 'Engineering',
providers: [provide(REACTOR, { useClass: FusionReactor })],
});
const Tactical = defineModule({
name: 'Tactical',
imports: [Engineering],
providers: [provide(COMPUTER, { useClass: QuantumComputer })],
});
const error = await Nexus.create(Tactical).catch((caught: unknown) => caught);
const errors = isNexusError(error, 'NEXUS_BLUEPRINT_INVALID')
? error.errors
: [];
const codes = errors.map((inner) => inner.code); // -> ['NEXUS_MISSING_PROVIDER']
console.log(codes);Try it: Save the region as exports.ts and run npx tsx exports.ts. It prints [ 'NEXUS_MISSING_PROVIDER' ]: Tactical imports Engineering, and Engineering keeps REACTOR private. Add exports: [REACTOR], to the Engineering module and run it again. Nexus.create resolves, so error holds the ship and no error, and the program prints []. Take the line out again to see the code return.
5. Bootstrapping the Container
The new Nexus() and set() pattern is replaced by a single, atomic Nexus.create() call. This is where the static graph validation happens. Its root is a module, or an array of providers (a plain { providers, imports, exports } object also works). The container is sealed once create resolves. A provider registered after startup becomes a module that ship.load() adds (Adding Modules After Startup).
0.3 (Legacy):
Anti-example
const container = new Nexus();
container.set(REACTOR, { useClass: FusionReactor });
container.set(QuantumComputer);
const computer = container.get(QuantumComputer);0.4 (Current):
import { Nexus, Token, provide } from '@nexusdi/core';
interface IReactorCore {
readonly output: number;
}
interface IShipComputer {
status(): string;
}
const REACTOR = new Token<IReactorCore>('ReactorCore');
const COMPUTER = new Token<IShipComputer>('ShipComputer');
class FusionReactor implements IReactorCore {
readonly output = 1.21;
}
class QuantumComputer implements IShipComputer {
static deps = [REACTOR] as const;
constructor(private readonly reactor: IReactorCore) {}
status() {
return `Reactor output ${this.reactor.output} GW.`;
}
}
await using ship = await Nexus.create([
provide(REACTOR, { useClass: FusionReactor }),
provide(COMPUTER, { useClass: QuantumComputer }),
]);
const status = ship.get(COMPUTER).status(); // -> 'Reactor output 1.21 GW.'
console.log(status);Try it: Save the region as bootstrap.ts and run npx tsx bootstrap.ts. It prints Reactor output 1.21 GW. Delete the provide(REACTOR, ...) line and run it again. The run stops with an uncaught NEXUS_BLUEPRINT_INVALID that lists NEXUS_MISSING_PROVIDER with token=ReactorCore requester=ShipComputer module=root: the check runs before any class is built. Put the line back.
Provider Literals
Provider objects in the 0.3 { token, useClass } and { token, useValue } shape still work in providers. A useFactory literal needs annotated parameters, or provide(), which infers them from deps. defineModule checks each literal with the rules provide() applies:
import { Nexus, Token, defineModule } from '@nexusdi/core';
interface IReactorCore {
readonly output: number;
}
const REACTOR = new Token<IReactorCore>('ReactorCore');
const CALLSIGN = new Token<string>('Callsign');
class FusionReactor implements IReactorCore {
readonly output = 1.21;
}
const Engineering = defineModule({
name: 'Engineering',
providers: [
{ token: REACTOR, useClass: FusionReactor },
{ token: CALLSIGN, useValue: 'Meridian' },
],
});
await using ship = await Nexus.create(Engineering);
const callsign = ship.get(CALLSIGN); // -> 'Meridian'
console.log(callsign);Try it: Save the region as literals.ts and run npx tsx literals.ts. It prints Meridian. Change useValue: 'Meridian' to useValue: 42 and npx tsc reports TS2322 on that line, because CALLSIGN is a Token<string>. Change it back.
6. Lifetimes and Scopes
- Scopes:
createChildContainer()is replaced bycreateScope({ request }). Call it asawait ship.createScope({ request }). A provider withlifetime: 'scoped'is built once per scope. - Transients:
singleton: falseis nowlifetime: 'transient'. 0.3 ignored that flag and cached every instance. - Eager Loading: 0.3 built everything lazily. 0.4 builds all singletons at startup. Use
eager: falseto restore lazy initialization for expensive providers.
REQUEST resolves to the request the scope was created with. The project declares that request’s shape once, by augmenting NexusRequest in a .d.ts file (Scopes and the REQUEST Token):
Not run
// request.d.ts
import '@nexusdi/core';
declare module '@nexusdi/core' {
interface NexusRequest {
mission: { id: string; target: string };
}
}0.3 (Legacy):
Anti-example
const child = container.createChildContainer();
child.set(MISSION, { useValue: { id: 'survey-7', target: 'Kepler-442b' } });0.4 (Current):
import { Nexus, REQUEST, Token, defineModule, provide } from '@nexusdi/core';
interface Mission {
readonly id: string;
readonly target: string;
}
const MISSION = new Token<Mission>('Mission');
const Tactical = defineModule({
name: 'Tactical',
providers: [
provide(MISSION, {
useFactory: (request) => request.mission,
deps: [REQUEST],
lifetime: 'scoped',
}),
],
});
await using ship = await Nexus.create(Tactical);
await using shuttle = await ship.createScope({
request: { mission: { id: 'survey-7', target: 'Kepler-442b' } },
});
const target = shuttle.get(MISSION).target; // -> 'Kepler-442b'
console.log(target);Try it: Save request.d.ts and the region as scopes.ts in one folder, and run npx tsx scopes.ts. It prints Kepler-442b. Without request.d.ts, npx tsc reports TS2339 on request.mission, because NexusRequest has no mission. With the file back, change shuttle.get(MISSION) to ship.get(MISSION) and run it again: the run stops with NEXUS_SCOPE_REQUIRED for token=Mission, because a scoped provider is built only inside a scope. Change it back.
Restoring First-Use Timing
0.3 built each provider at its first get(). 0.4 builds every singleton at create, so a wiring mistake surfaces at startup. eager: false on a binding restores first-use timing for a provider with an expensive or side-effecting constructor (Lifetimes):
import { Nexus, Token, provide } from '@nexusdi/core';
interface INavCharts {
plot(to: string): string;
}
const NAV_CHARTS = new Token<INavCharts>('NavCharts');
const log: string[] = [];
class StarCharts implements INavCharts {
constructor() {
log.push('charts loaded');
}
plot(to: string) {
return `course to ${to}`;
}
}
await using ship = await Nexus.create([
provide(NAV_CHARTS, { useClass: StarCharts, eager: false }),
]);
const atStartup = log.length; // -> 0
console.log(atStartup);
ship.get(NAV_CHARTS);
const afterGet = log.length; // -> 1
console.log(afterGet);Try it: Save the region as eager.ts and run npx tsx eager.ts. It prints 0 and then 1: the constructor runs at the first get(). Delete , eager: false and run it again. It prints 1 and 1, because create now builds the charts.
7. Replacing Providers in Tests
A 0.3 test that called container.set(T, { useValue }) to swap a provider uses createTestingContainer(root).override(T, { useValue }) from @nexusdi/testing. The testing container runs the full compiler over the real graph (Testing and Overrides).
0.3 (Legacy):
Anti-example
container.set(NAV_CHARTS, { useValue: fakeCharts });0.4 (Current):
import { Token, defineModule, provide } from '@nexusdi/core';
import { createTestingContainer } from '@nexusdi/testing';
interface INavCharts {
plot(to: string): string;
}
const NAV_CHARTS = new Token<INavCharts>('NavCharts');
class StarCharts implements INavCharts {
plot(to: string) {
return `course to ${to}`;
}
}
const fakeCharts: INavCharts = { plot: () => 'loopback' };
const Tactical = defineModule({
name: 'Tactical',
providers: [provide(NAV_CHARTS, { useClass: StarCharts })],
exports: [NAV_CHARTS],
});
await using ship = await createTestingContainer(Tactical)
.override(NAV_CHARTS, { useValue: fakeCharts })
.create();
const course = ship.get(NAV_CHARTS).plot('Kepler-442b'); // -> 'loopback'
console.log(course);Try it: Save the region as tests.ts and run npx tsx tests.ts. It prints loopback. Change useValue: fakeCharts to useValue: 42 and npx tsc reports TS2769 on the override( call, because 42 is not an INavCharts. Change it back.
8. Error Codes
Every 0.4 error extends NexusError and carries a stable code. Core’s own message is one line: the code with the error’s fields, and a link to the code’s page. Register errors() from @nexusdi/errors, or devtools(), for the full message with its fix. Code that matched 0.3 message text compares error.code in 0.4.
The 0.3 error classes map to 0.4 classes:
ContainerExceptionbecomesNexusError.NoProviderbecomesMissingProviderErrororNotVisibleError.InvalidTokenbecomesInvalidTokenError, andInvalidProviderbecomesInvalidProviderError.InvalidModulebecomesInvalidModuleError.
0.3 (Legacy):
Anti-example
try {
container.get(NAV_CHARTS);
} catch (error) {
if (error instanceof NoProvider) recover();
}0.4 (Current):
import { MissingProviderError, Nexus, Token } from '@nexusdi/core';
import { isNexusError } from '@nexusdi/core';
import { provide } from '@nexusdi/core';
import { errors } from '@nexusdi/errors';
interface INavCharts {
plot(to: string): string;
}
interface IShipComputer {
course(to: string): string;
}
const NAV_CHARTS = new Token<INavCharts>('NavCharts');
const COMPUTER = new Token<IShipComputer>('ShipComputer');
class QuantumComputer implements IShipComputer {
static deps = [NAV_CHARTS] as const;
constructor(private readonly charts: INavCharts) {}
course(to: string) {
return this.charts.plot(to);
}
}
const error = await Nexus.create(
[provide(COMPUTER, { useClass: QuantumComputer })],
{
plugins: [errors()],
},
).catch((caught: unknown) => caught);
const inner = isNexusError(error, 'NEXUS_BLUEPRINT_INVALID')
? error.errors
: [];
const first = inner[0];
const isMissing = first instanceof MissingProviderError; // -> true
console.log(isMissing);
const code = first?.code; // -> 'NEXUS_MISSING_PROVIDER'
console.log(code);
const message = first?.message.split('\n')[0]; // -> '[NEXUS_MISSING_PROVIDER] ShipComputer (module root) depends on NavCharts, but no provider of NavCharts is visible in root.'
console.log(message);Try it: Save the region as codes.ts and run npx tsx codes.ts. It prints true, NEXUS_MISSING_PROVIDER and the full first line, [NEXUS_MISSING_PROVIDER] ShipComputer (module root) depends on NavCharts, but no provider of NavCharts is visible in root. Delete the plugins: [errors()], option and run it again. The last line becomes core’s one-line message, [NEXUS_MISSING_PROVIDER] token=NavCharts requester=ShipComputer module=root. followed by the link to the code’s page. The code and the class stay the same. Put the option back.
Summary of API Changes
These 0.3 exports and members map to 0.4 as follows. The last six rows have no step above.
| 0.3 Concept | 0.4 Replacement |
|---|---|
container.set() | Nexus.create() / provide() |
DynamicModule | defineModule() |
createChildContainer() | createScope() |
@Service | static deps / @Injectable |
@Provider | static deps with provide(), or @Injectable from @nexusdi/decorators |
| Symbol tokens | Token<T> |
container.resolve(Class) | provide(Class, { lifetime: 'transient' }) and ship.get(Class). ship.resolve(deps) takes a deps tuple or map, and resolve(Class) throws NEXUS_INVALID_TOKEN. |
container.list(), container.clear() | list() is gone: register devtools() in Nexus.create’s plugins, and graph(ship) from @nexusdi/devtools reports the graph. clear() is gone: dispose the ship with await using or [Symbol.asyncDispose](). |
isToken, isService, isModuleClass and the other guards | No 0.4 form. Core exports isNexusError and isForeign. |
getMetadata, setMetadata, METADATA_KEYS, SYMBOL_METADATA | No 0.4 form: core keeps no public metadata store. |
import Nexus from '@nexusdi/core' | The named export import { Nexus } from '@nexusdi/core'. 0.4 has no default export. |
Token.create(id), Token.createUnique(), token.equals(other), token.toSymbol() | new Token<T>(description) makes each token, === compares two, and toSymbol() is gone. |
IContainer, ModuleProvider | The Nexus type, and ProviderEntry. |
Behaviour That Changes Without a Compile Error
Some changes compile in a 0.3 project and behave differently under 0.4. npx tsc does not list these, so check each one by hand:
- A constructor parameter with no
@Injectwasundefinedin 0.3, because 0.3 read emitted parameter types that nothing wrote. In 0.4static depslists every parameter, and each one is injected. - Every singleton is built at
create.eager: falserestores first-use timing. - 0.3 never called
onInit. 0.4 calls it insidecreate, after every singleton is built. has()answers for visibility: it returnsfalsefor a token another module keeps private, and for a module class.- Two
forRoot()calls build two module instances. CallforRoot()once and import the result everywhere. - One token provided in two modules builds two singletons, one for each module.
- 0.3 disposed nothing. 0.4 disposes every instance it owns, a factory’s result included, when the container is disposed.
- Every error message starts with its code in brackets, such as
[NEXUS_MISSING_PROVIDER]. @Optionalstill threwNoProviderin 0.3 when no provider existed.optional()in 0.4 resolves toundefined.
import { Nexus, Token, defineModule, provide } from '@nexusdi/core';
interface IReactorCore {
readonly output: number;
}
const REACTOR = new Token<IReactorCore>('ReactorCore');
class FusionReactor implements IReactorCore {
readonly output = 1.21;
}
const Engineering = defineModule({
name: 'Engineering',
providers: [provide(REACTOR, { useClass: FusionReactor })],
});
await using ship = await Nexus.create(
defineModule({ name: 'Meridian', imports: [Engineering] }),
);
const privateToken = ship.has(REACTOR); // -> false
console.log(privateToken);Try it: Save the region as visibility.ts and run npx tsx visibility.ts. It prints false, because Engineering keeps REACTOR private. Add exports: [REACTOR], to the Engineering module and run it again. It prints true. Take the line out again.