Skip to content

Migrating to 2.0

2.0 keeps Node 20+ and standard TypeScript decorators, but fixes observable behaviors from 1.x. This guide covers the breaking changes and links to the tests that pin down each contract.

In 1.x, @TransitionTo changed state right after invoking the method, even when it returned a pending promise or eventually rejected. In 2.0, a Promise or PromiseLike only confirms the transition once it resolves; a rejection keeps the previous state and propagates without wrapping the reason.

Before (1.x):

@When('draft')
@TransitionTo('review')
async submit(): Promise<void> {
await saveDraft();
}
const result = doc.submit();
doc.state; // 'review' before result resolves

After (2.0):

const result = doc.submit();
doc.state; // 'draft' while result is pending
await result;
doc.state; // 'review'

If await result rejects, doc.state stays 'draft'. Keep @When above @TransitionTo so the state guard runs first. Verifiable contract: tests/state-machine-async.test.ts and tests/state-machine.test.ts.

@Timeout(ms) still rejects the returned promise when the limit is exceeded and clears its timer on resolve, reject, or timeout. It does not inject an AbortSignal and cannot safely cancel an operation that doesn’t cooperate: the original work may keep running and its result is simply discarded.

Before and after, use the decorated result as your wait limit:

class Api {
@Timeout(500)
async fetchUser(id: string): Promise<User> {
return requestUser(id);
}
}
await new Api().fetchUser('42'); // rejects after 500ms if it hasn't finished

If you need cancellation, make your own operation accept and observe a signal; 2.0 does not add arguments or implicit cancellation. Verifiable contract: tests/timeout.test.ts.

@Singleton and @Flyweight isolate inheritance

Section titled “@Singleton and @Flyweight isolate inheritance”

In 1.x, a decorated base class could accidentally share its instance or cache with an undecorated subclass. In 2.0, the cache is partitioned by the effective constructor (newTarget): base and derived are distinct even with the same argument, and each class keeps its own reuse.

Before (1.x):

@Singleton
class Base {}
class Derived extends Base {}
new Base() === new Derived(); // could be true

After (2.0):

const base = new Base();
const derived = new Derived();
base === derived; // false
new Base() === base; // true
new Derived() === derived; // true

The same isolation applies to @Flyweight: new Base('x') and new Derived('x') don’t share an entry, but each class reuses its own entry for 'x'.

There’s a JavaScript limitation for @Singleton: if the subclass isn’t decorated, its constructor still runs on every new Derived(...) after super(). It can mutate its own fields with later arguments even while reusing the same derived instance. Decorate the subclass too if you need to control that initialization. Verifiable contract: tests/singleton.test.ts, tests/flyweight.test.ts, and tests/characterization/inheritance.test.ts.

Container now detects a cycle during synchronous resolution and throws the public CircularDependencyError. The error exposes tokenPath and a readable message, e.g. A -> B -> A.

Before (1.x):

container.resolve(A); // an A -> B -> A cycle exhausted the stack

After (2.0):

try {
container.resolve(A);
} catch (error) {
if (error instanceof CircularDependencyError) {
console.log(error.tokenPath); // ['A', 'B', 'A']
}
}

The container remains deliberately small: synchronous-only providers (useValue, useClass, useFactory), singleton and transient scopes; there are no async providers, additional scopes, child containers, disposal, or unregister. Verifiable contract: tests/injection.test.ts and tests/characterization/injection-contract.test.ts.

2.0 adds a functional API (/operators, /pipe, /run, /policy), observability (/observability), async stores (/store), and Node request context (/node). They are new, opt-in subpaths: code that only uses decorators does not need to change for them. The library stays dependency-free at runtime and requires Node 20+.