never-di
v0.0.21
Published
A lightweight, immutable, dependency-free, function-only dependency injection (DI) container for TypeScript.
Maintainers
Readme
never-di
A lightweight, dependency-free, function-only dependency injection (DI) container for TypeScript.
No decorators, no reflection, no classes — just plain functions with strong type safety.
Design
never-di is intentionally minimal and built with these principles:
- Function-only: factories are plain functions. No classes, no decorators.
- Compile-time type safety: dependency errors are caught at compile time, not runtime.
- Immutable builder: every builder step returns a new draft; the receiver is never mutated, so branching a draft never leaks state across branches.
- Multi-binding: register multiple factories for the same token and resolve an array. The element type is the inferred common shape of the factories' returns — no declaration needed.
- Lazy dependencies: explicitly mark tokens as lazy, and they resolve to memoized thunks.
- Singletons by design: factories run once and results are cached.
- Cycle-safe: eager dependency cycles fail with a clear
circular dependency detectederror instead of a stack overflow. - Lightweight: small, simple, no external dependencies.
Scope Model
There is exactly one scope: singleton.
- Each token is resolved once per container.
- Factories run only on first resolution, then results are cached.
- Similar to ESM modules: load once, reuse everywhere.
If you need per-request behavior, create a new container instance.
If you need transient behavior, put it inside your factory.
API
createContainerDraft()
Start building a container draft:
import { createContainerDraft } from "never-di";The draft exposes:
assign(factory)– assign a single factory to its token.assignMany([factories])– assign multiple factories for the same token (multi-binding).defineLazy(factory)– mark a factory’s token as lazy; the container enforces that it must be assigned before sealing. Intended to allow direct cyclic dependencies.seal()– finalize the container; returns a runtimeContainerwithresolveandbind.
Builder stages
The draft is a two-stage builder:
- Stage 1 (a fresh draft, or after
defineLazy) exposesdefineLazy,assign, andassignMany. - Stage 2 (after the first
assign/assignMany) additionally exposesseal.
seal() is therefore unavailable until at least one factory has been assigned — an empty draft cannot be sealed.
Factory metadata
Factories carry two optional metadata fields:
factory.token– the unique key the factory is registered under. Required forassign,assignMany, anddefineLazy.factory.dependsOn– an ordered,as consttuple of dependency tokens, mapped positionally onto the factory's parameters.
A factory passed to bind is a one-off procedure and does not need a token — it is executed against the container but never registered.
Usage
Basics
import { createContainerDraft } from "never-di";
foo.token = "foo" as const;
function foo(): number {
return 1;
}
bar.token = "bar" as const;
bar.dependsOn = ["foo"] as const;
function bar(foo: number): string {
return `bar(${foo})`;
}
const container = createContainerDraft()
.assign(foo)
.assign(bar)
.seal();
console.log(container.resolve("bar")); // "bar(1)"Multi-binding
import { createContainerDraft } from "never-di";
h1.token = "handler" as const;
function h1(): string { return "h1"; }
h2.token = "handler" as const;
function h2(): string { return "h2"; }
const container = createContainerDraft()
.assignMany([h1, h2])
.seal();
console.log(container.resolve("handler")); // ["h1", "h2"]Lazy dependencies ( breaking cycles )
import { createContainerDraft } from "never-di";
Foo.token = "Foo" as const;
Foo.dependsOn = ["Bar"] as const;
function Foo(b: { bar: string }) {
return { foo: "foo->" + b.bar };
}
Bar.token = "Bar" as const;
Bar.dependsOn = ["Foo"] as const;
function Bar(foo: () => { foo: string }) {
return { bar: "bar->" + foo().foo };
}
const c = createContainerDraft()
.defineLazy(Foo)
.assign(Foo)
.assign(Bar)
.seal();
const bar = c.resolve("Bar");
console.log(bar.bar);The bind method ( usually for side-effects )
import { createContainerDraft } from "never-di";
Foo.token = "Foo" as const;
Foo.dependsOn = ["Bar"] as const;
function Foo(b: { bar: string }) {
return { foo: "foo->" + b.bar };
}
Bar.dependsOn = ["Foo"] as const;
function Bar(foo: () => { foo: string }) {
console.log({ bar: "bar->" + foo().foo });
}
const c = createContainerDraft()
.defineLazy(Foo)
.assign(Foo)
.seal();
const bar = c.bind(Bar);
bar();Compile-time guarantees (with examples)
1) Only depend on already-registered tokens
✅ good
Foo.token = "Foo" as const;
function Foo() { return 123; }
Bar.token = "Bar" as const;
Bar.dependsOn = ["Foo"] as const;
function Bar(foo: number) { return foo.toString(); }
createContainerDraft()
.assign(Foo)
.assign(Bar)
.seal();❌ type error (unknown token)
Bar.token = "Bar" as const;
Bar.dependsOn = ["Missing"] as const;
function Bar(x: unknown) { return x; }
createContainerDraft()
.assign(Bar);
// Error: Bar.dependsOn includes "Missing", which is not in the draft registry.2) Parameter types must match dependency token types
✅ good
Foo.token = "Foo" as const;
function Foo() { return 123; }
Bar.token = "Bar" as const;
Bar.dependsOn = ["Foo"] as const;
function Bar(foo: number) { return foo.toFixed(2); }
createContainerDraft().assign(Foo).assign(Bar).seal();❌ type error
Foo.token = "Foo" as const;
function Foo() { return 123; }
Bar.token = "Bar" as const;
Bar.dependsOn = ["Foo"] as const;
function Bar(foo: string) {
return foo.toUpperCase();
}
// Error: Parameter 1 of Bar must be the type produced by token "Foo" (number).3) Dependency order is enforced
✅ good
A.token = "A" as const;
function A() { return { a: 1 }; }
B.token = "B" as const;
function B() { return { b: 2 }; }
C.token = "C" as const;
C.dependsOn = ["A", "B"] as const;
function C(a: { a: number }, b: { b: number }) {
return [a.a, b.b];
}
createContainerDraft().assign(A).assign(B).assign(C).seal();❌ type error
C.token = "C" as const;
C.dependsOn = ["A", "B"] as const;
function C(b: { b: number }, a: { a: number }) {
return [a.a, b.b];
}
// Error: Param1 must match "A", Param2 must match "B"4) Lazy tokens resolve to thunks
✅ good
Foo.token = "Foo" as const;
function Foo() { return { foo: 42 }; }
Bar.token = "Bar" as const;
Bar.dependsOn = ["Foo"] as const;
function Bar(foo: () => { foo: number }) {
return foo().foo + 1;
}
createContainerDraft()
.defineLazy(Foo)
.assign(Foo)
.assign(Bar)
.seal();❌ type error
Bar.token = "Bar" as const;
Bar.dependsOn = ["Foo"] as const;
function Bar(foo: { foo: number }) {
return foo.foo + 1;
}
// Error: Token "Foo" is lazy; dependents must accept () => { foo: number }5) Multi-bind infers the factories' common shape
A token bound by several factories resolves to an array. There is nothing to declare — the container infers the element type from the factories' return types and computes their common shape: the members present in every return, each typed as the union of that member's types across the factories.
The common shape is a single flat object regardless of how many factories are bound, so it stays cheap and readable (no growing union to wade through in your editor). The coherence rule falls out of the same computation:
- All factories returning objects that share ≥1 member → the common shape of those members. The canonical use case: distinct command/handler shapes sharing an interface or discriminant.
- All factories returning the same primitive → that primitive (e.g.
string[]). - Objects that share no member, differing primitives, or a mix of objects and primitives → rejected as incoherent.
Note: because the element type is the flat common shape (not a union of the concrete return types),
resolveresults expose only the shared members and do not narrow on a discriminant. Consume them through the shared interface.
✅ good — heterogeneous command shapes sharing { name, handler } (no declaration)
interface GetValue { name: "get_value"; handler: () => void; getOnly: number }
interface PutValue { name: "put_value"; handler: () => void; putOnly: boolean }
GetCmd.token = "Command" as const;
function GetCmd(): GetValue { return { name: "get_value", handler() {}, getOnly: 1 }; }
PutCmd.token = "Command" as const;
function PutCmd(): PutValue { return { name: "put_value", handler() {}, putOnly: true }; }
const c = createContainerDraft().assignMany([GetCmd, PutCmd]).seal();
const commands = c.resolve("Command"); // { name: string; handler: () => void }[]
commands.forEach((cmd) => cmd.handler()); // shared members only (no getOnly/putOnly)A consumer can depend on the token and type its parameter as the shared interface (or anything wider) — the resolved array is assignable to it:
interface Command { name: string; handler: () => void }
KvService.token = "Service" as const;
KvService.dependsOn = ["Command"] as const;
function KvService(commands: Command[]) { /* ... */ }
createContainerDraft().assignMany([GetCmd, PutCmd]).assign(KvService).seal();✅ good — identical primitive returns
H1.token = "Handler" as const; function H1() { return "h1"; }
H2.token = "Handler" as const; function H2() { return "h2"; }
createContainerDraft().assignMany([H1, H2]).seal(); // string[]❌ type error — returns share no common members
H1.token = "Handler" as const; function H1() { return { a: 1 }; }
H2.token = "Handler" as const; function H2() { return { b: 2 }; }
createContainerDraft()
.assignMany([H1, H2])
.seal();
// Error: assignMany factory return types share no common members
// (seal does not exist on the returned error type)6) resolve(token) is token-safe and value-typed
✅ good
Foo.token = "Foo" as const;
function Foo() { return 123; }
const c = createContainerDraft().assign(Foo).seal();
const x = c.resolve("Foo"); // number
x.toFixed(2);❌ type error
Foo.token = "Foo" as const;
function Foo() { return 123; }
const c = createContainerDraft().assign(Foo).seal();
c.resolve("Nope");
// Error: Argument of type '"Nope"' is not assignable to parameter of type '"Foo"'Note: an empty draft cannot be sealed —
seal()only becomes available after the firstassign/assignMany.createContainerDraft().seal()is a compile-time error (Property 'seal' does not exist).
7) Predeclare tokens with defineLazy and wire later
✅ good
A.token = "A" as const;
A.dependsOn = ["B"] as const;
function A(b: () => string) { return "A->" + b(); }
B.token = "B" as const;
B.dependsOn = ["A"] as const;
function B(a: () => string) { return "B->" + a(); }
const c = createContainerDraft()
.defineLazy(A)
.assign(A)
.assign(B)
.seal();
c.resolve("A");8) bind(fn) checks the dependency signature too
✅ good
Svc.token = "Svc" as const;
function Svc() { return { ping: () => "pong" }; }
Runner.dependsOn = ["Svc"] as const;
function Runner(svc: { ping: () => string }) {
return () => console.log(svc.ping());
}
const c = createContainerDraft().assign(Svc).seal();
c.bind(Runner)(); // prints "pong"❌ type error
Runner.dependsOn = ["Svc"] as const;
function Runner(svc: { nope: () => void }) { }
// Error: Parameter for "Svc" must match its produced type9) Bonus: end-to-end misuse is caught
❌ type error
Foo.token = "Foo" as const;
function Foo() { return 123; }
const c = createContainerDraft().assign(Foo).seal();
const v = c.resolve("Foo"); // number
v.toUpperCase();
// Error: 'toUpperCase' does not exist on type 'number'Notes
- No class support, no decorators, no custom scopes — intentionally out of scope.
- Contributions are welcome for:
- Bug reports
- Unit tests
- Type improvements
Inspired by typed-inject, but with a simpler, function-only design.
