@pajecawav/di
v0.0.1
Published
Maintainers
Readme
@pajecawav/di
Dependency injection container for TypeScript with constructor injection via
design:paramtypes metadata, an instance lifecycle (init / update /
destroy), and React 19 bindings for MVVM.
- Zero dependencies except the
reflect-metadatapolyfill. - SSR-safe:
scope.globalmeans "one instance per root container", not "one per process". - React 19 / StrictMode-safe: models resolve during render with pure constructors, lifecycle runs in effects, double-mount revives a fresh instance instead of re-initializing a dead one.
Setup
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
},
}reflect-metadata is imported by the package entry — no extra setup.
Core (@pajecawav/di)
Lifetimes
import { scope } from "@pajecawav/di";
@scope.global // one instance per root container (SSR: per request)
class ApiService {}
@scope.container // one instance per first-resolving scope (page, model scope)
class PageModel {}
@scope.transient // new instance every resolution
class Validator {}Decorators only record metadata — registration happens lazily at first
resolution, so importing modules has no global side effects. Undecorated
classes resolve as transient.
Containers
import { createRootContainer, rootContainer } from "@pajecawav/di";
const root = createRootContainer(); // fresh isolated root (SSR, tests)
const scope = root.createChild(); // child scope, shadows parent registrationsregister(token, { useClass | useFactory | useValue | useToken }, lifetime?)— explicit registration (singletonby default). Duplicate registration throws.replace(token, provider)— static override before first resolve; in a child container it shadows the parent. Live swapping is not supported.resolve(token),isRegistered(token),createChild().dispose()— async, cascades into children and destroys every instance the container created (after itsinitsettled).waitForInit()— awaits all pendinginits of this container and its children; never rejects (failed inits are logged and exposed viainitPromise(instance)).
Symbol tokens
import { createToken, inject } from "@pajecawav/di";
interface Logger {
/* ... */
}
const LoggerToken = createToken<Logger>("Logger");
root.register(LoggerToken, { useValue: console });
class UserService {
constructor(@inject(LoggerToken) private logger: Logger) {}
}Class-typed constructor parameters (including Props<T>) need no @inject —
metadata magic handles them.
Props token
Runtime props are injected as a read-only holder:
import { Props, init, update, destroy, type ViewModelLifecycle } from "@pajecawav/di";
interface PageProps {
id: string;
}
@scope.container
class PageModel implements ViewModelLifecycle<PageProps> {
constructor(readonly props: Props<PageProps>) {} // readable in the constructor
[init]() {
/* fetch page by this.props.current.id */
}
[update](props: PageProps) {
/* called only when props changed (shallow) */
}
[destroy]() {
/* cancel in-flight work */
}
}Lifecycle methods are optional symbol-keyed methods — they never collide with
the model's own API. update is not called at init time; the initial
props are already in the holder.
Tests
import { createTestContainer } from "@pajecawav/di";
const container = createTestContainer([
[ApiToken, fakeApi], // plain value shorthand
[Logger, { useValue: silentLogger }], // or full provider objects
]);React layer (@pajecawav/di/react, peer: react ^19)
Shared subtree model
import { createViewModel } from "@pajecawav/di/react";
const [PageModelProvider, usePageModel] = createViewModel(PageModel);
function Page({ id }: { id: string }) {
return (
<PageModelProvider props={{ id }}>
<Content />
</PageModelProvider>
);
}
function Content() {
const model = usePageModel(); // same instance for the whole subtree
// ...
}The provider resolves the model once in its own scope and provides that scope as the DI container for the subtree, so nested models inject the same page model instance:
@scope.transient
class WidgetModel {
constructor(readonly page: PageModel) {} // the provider's instance
}Private component model
import { useModel } from "@pajecawav/di/react";
function Widget() {
const model = useModel(WidgetModel, { filter: "open" }); // private to this component
}Container scopes
import { DIContainerProvider, DIScopeProvider, rootContainer } from "@pajecawav/di/react";
<App>
<DIContainerProvider container={rootContainer}>
<DIScopeProvider>{/* route subtree sees a child container */}</DIScopeProvider>
</DIContainerProvider>
</App>;DIScopeProvider does not dispose itself — the scope lives until its owning
container is disposed.
Lifecycle semantics
- The model is resolved during render — the constructor must be pure (dependency wiring only). Wasted instances from discarded concurrent renders are garbage-collected with their scopes.
[init]runs on mount (in an effect, never during render);[destroy]runs on unmount, afterinitsettled. Both may be async;destroymust cancel in-flight work.[update]runs when props change (shallow compare), never at init.- StrictMode double-mount: cleanup destroys the instance and defers the scope disposal to a microtask; the immediate re-mount cancels the disposal and revives a fresh instance from the same scope. Net effect in dev: init runs twice on two instances, exactly the "write resilient cleanup" contract React asks for. In production everything happens once.
SSR pattern
import { createRootContainer } from "@pajecawav/di";
async function handleRequest() {
const request = createRootContainer(); // fresh per request
// optional: request.register(ConfigToken, { useValue: perRequestConfig });
// Drive data-fetching inits before rendering if needed:
const page = request.resolve(PageModel);
request.initialize(page);
await request.waitForInit();
const html = renderToString(
<DIContainerProvider container={request}>
<Page />
</DIContainerProvider>,
);
await request.dispose(); // destroys every instance created for the request
return html;
}rootContainer (the default export of the core) exists for SPA convenience;
never use it on the server.
Non-goals
- Property injection.
- Live hot-swapping of resolved instances (use
replacebefore resolve). - Async factories — construction is synchronous; async work belongs in
[init]. - Circular dependencies — refactor, or resolve lazily inside a factory method.
