@tie-di/core
v0.1.0
Published
Compile-time verified dependency injection for TypeScript. If it compiles, it resolves.
Maintainers
Readme
@tie-di/core
A dependency injection container for TypeScript. No decorators, no reflection, no runtime dependencies.
Services are identified by tokens. A module maps each token to a provider: a class, a factory, a value, or
an acquire/release pair. build() checks the module and returns a container, constructing everything it
owns in dependency order. Missing and mismatched dependencies are type errors. Lifetime conflicts and
cycles are reported by build() before any service is constructed.
import { build, module, token } from '@tie-di/core';
interface Config { level: string }
interface Logger { info(msg: string): void }
const Config = token<Config>()('Config');
const Logger = token<Logger>()('Logger');
class ConsoleLogger implements Logger {
constructor(private cfg: Config) {}
info(msg: string) { console.log(`[${this.cfg.level}] ${msg}`); }
}
const app = build(
module()
.value(Config, { level: 'debug' })
.class(Logger, ConsoleLogger, [Config]),
);
app.get(Logger).info('hello'); // [debug] helloOmitting the Config provider is a type error:
error TS2339: Property 'get' does not exist on type 'Rejected<"no provider for 'Config'">'.Two details of that snippet are worth pointing out. token is called twice because TypeScript will not
accept one explicit type argument and infer another; the service type goes in the first call and the name in
the second. interface Logger and const Logger coexist because types and values occupy separate
namespaces, so a token and the interface it stands for can share a name.
@tie-di/coreis not yet published to npm. Pre-1.0; the API may change.
Registering services · Project layout · Testing · Scopes · Shutdown · Async setup · Lists · Optional dependencies · Runtime arguments · Borrowed resources · Circular dependencies · Troubleshooting · API · Inspecting
Registering services
module()
// new SqlUserRepo(db, logger)
.class(UserRepo, SqlUserRepo, [Db, Logger])
// when construction isn't just `new`
.factory(Clock, [], () => ({ now: () => Date.now() }))
// something you already built
.value(Config, loadConfig())
// anything that needs closing
.resource(Db, [Config], {
acquire: (cfg) => openPool(cfg.url),
release: (db) => db.end(),
})
// one item of a list — see Lists
.contribute(Plugins, [Logger], (log) => new AuditPlugin(log));Registration order is not significant. Requirements are collected as providers are added and checked once,
at build().
Dependency lists are matched against constructor parameters by position and type. A transposed pair reports on both entries:
error TS2322: Type 'Token<Db, "Db", "singleton">' is not assignable to type 'Token<Logger, string, Lifetime>'.
Property 'info' is missing in type 'Db' but required in type 'Logger'.Project layout
// contracts.ts
export interface Logger { info(msg: string): void }
export const Logger = token<Logger>()('Logger');// console-logger.ts
import type { Config, Logger } from './contracts.js';
export class ConsoleLogger implements Logger {
constructor(private cfg: Config) {}
info(msg: string) { console.log(`[${this.cfg.level}] ${msg}`); }
}// wiring.ts
export const InfraModule = module()
.value(Config, loadConfig())
.class(Logger, ConsoleLogger, [Config])
.resource(Db, [Config], { acquire: (c) => openPool(c.url), release: (d) => d.end() });
export const AppModule = module()
.include(InfraModule)
.class(UserRepo, SqlUserRepo, [Db, Logger]);Implementations import interfaces with import type and never import tokens, so they do not depend on
@tie-di/core and can be constructed directly.
Modules need not be self-contained. UserRepo above depends on Db from another module; build() checks
the combined graph. Exporting modules rather than containers is what allows a test to register over part of
the wiring.
Alternative implementations are selected by choosing a module, since each one declares its own dependencies:
const LoggingModule = process.env.CI
? module().class(Logger, SilentLogger, [])
: module().class(Logger, ConsoleLogger, [Config]);
const app = build(module().value(Config, loadConfig()).include(LoggingModule));Consumers of Logger are unaffected.
Testing
Implementations are ordinary classes and can be tested without a container:
const lines: string[] = [];
const repo = new SqlUserRepo(fakeDb, { info: (m) => lines.push(m) });
await repo.find('u1');
expect(lines).toEqual(['finding u1']);For integration tests, register over the parts of a real module you want to replace. The last registration for a token is the one used:
const app = build(AppModule.value(Db, fakeDb)); // real wiring, fake database
expect(await app.get(UserRepo).find('u1')).toEqual(/* … */);
await app.close();AppModule.value(…) returns a new module; the original is unchanged. There is no global state to reset
between tests.
A scope gives a test its own instances and releases them afterwards:
it('rolls back', async () => {
await using scope = app.scope();
await scope.get(UserRepo).find('u1');
}); // scoped services released here, pass or failScopes
A scope is a child lifetime, typically one per request, job or transaction.
| Declared with | One instance per | Resolve from | Released by |
| --- | --- | --- | --- |
| token<T>() | container | container or scope | container.close() |
| token.scoped<T>() | scope | scope only | scope.close() |
| token.transient<T>() | resolution | container or scope | nobody — see Borrowed resources |
const RequestCtx = token.scoped<{ current: Request | undefined }>()('RequestCtx');
const AppModule = module()
.include(InfraModule)
.factory(RequestCtx, [], () => ({ current: undefined }))
.factory(UserRepo, [Db, RequestCtx], (db, ctx) => new SqlUserRepo(db, ctx));
async function handleRequest(req: Request) {
await using scope = app.scope();
scope.get(RequestCtx).current = req;
scope.get(Db); // the process-wide pool, shared
return respond(scope.get(UserRepo));
} // ← this request's services released hereScopes nest, and closing one closes everything opened inside it. await scope.close() is the explicit form.
A service may depend on anything that lives at least as long as it does. The reverse is rejected: a singleton holding a scoped service would retain the first request's instance for the life of the process.
'Cache' (singleton) cannot depend on 'Session' (scoped) — it would outlive it.Resolving a scoped token from the container is also a type error, as there would be no scope to own the instance.
Shutdown
await app.close() releases everything the container owns, in reverse order of construction, so a service
always goes down before whatever it depended on:
module()
.resource(Pool, [], { acquire: () => openPool(), release: (p) => p.end() })
.resource(Queue, [Pool], { acquire: (p) => startQueue(p), release: (q) => q.drain() });
await app.close(); // drains the queue, then ends the poolClasses that already implement Symbol.asyncDispose or Symbol.dispose don't need a resource wrapper:
interface Watcher extends AsyncDisposable { start(): void }
const Watcher = token<Watcher>()('Watcher');
class FileWatcher implements Watcher {
start() { /* … */ }
async [Symbol.asyncDispose]() { await this.stop(); }
}
module().class(Watcher, FileWatcher, []); // stop() runs on closeAn implementation can't share the token's name, since both are values — hence FileWatcher. Only the
interface and the token share one, and that's the name everything else depends on.
Containers and scopes are both AsyncDisposable, so await using works on either.
- Instances registered with
.value()are never released. The container did not create them. - If a finalizer throws, the remaining finalizers still run. The failures are collected into one
ReleaseError, with each cause on.failures. close()is idempotent. Resolving from a closed container or scope throwsReleasedError.
Async setup
Return a promise from a factory and build becomes async:
const app = await build(
module()
.value(Config, loadConfig())
.factory(Db, [Config], async (cfg) => {
const db = openPool(cfg.url);
await db.connect();
return db;
}),
);
app.get(Db).query('select 1'); // already connected — get() is never asyncbuild is synchronous when no provider is async.
Construction is eager: every service the container owns is built before build returns. A provider that
throws therefore fails at startup, and the error identifies it:
try {
const app = await build(AppModule);
} catch (err) {
if (err instanceof ConstructionError) {
err.token; // 'Db'
err.cause; // Error: ECONNREFUSED postgres://…
}
throw err;
}Lists
For plugins, handlers or middleware, use a multi-token and contribute from anywhere:
const Plugins = multiToken<Plugin>()('Plugins');
module()
.contribute(Plugins, [Config], (cfg) => new AuthPlugin(cfg))
.contribute(Plugins, [Logger], (log) => new AuditPlugin(log));
app.get(Plugins); // readonly Plugin[], in registration orderOptional dependencies
optional(Token) gives you T | undefined, and unlike a normal dependency it doesn't have to be provided
at all:
module().factory(Service, [optional(Metrics)], (metrics) => ({
work: () => {
metrics?.count('work');
return doWork();
},
}));That module builds with no provider for Metrics. Adding one later requires no change to Service.
Runtime arguments
Some constructors need values the container can't know — a user id, a date range. Declare a token for the
factory and let .assisted() fill in the rest:
class Report {
constructor(
private db: Db, // from the container
private log: Logger, // from the container
readonly userId: string, // from you
readonly from: Date, // from you
) {}
}
const ReportFactory = token<(userId: string, from: Date) => Report>()('ReportFactory');
module().assisted(ReportFactory, Report, [Db, Logger]);
const makeReport = app.get(ReportFactory);
makeReport('u1', new Date('2026-01-01')); // a fresh Report each callThe dependency list covers the parameters preceding those in the token's signature; a mismatched count is a
type error. Instances produced by the factory are not owned by the container and are not released by
close().
Borrowed resources
A transient is constructed on every resolution and is not released by the container. For one that needs
releasing, depend on lease(Token), which resolves to a factory of leases. Each lease is released by its
holder:
interface Conn { query(sql: string): Promise<unknown>; release(): void }
const Conn = token.transient<Conn>()('Conn');
const app = build(
module()
.resource(Conn, [], { acquire: () => pool.take(), release: (c) => c.release() })
.factory(Job, [lease(Conn)], (take) => ({
run: async () => {
await using held = take();
return query(held.value);
}, // ← released here
})),
);await held.release() is the explicit form and is idempotent. A container or scope can also produce a lease
directly:
{
await using conn = app.lease(Conn);
await conn.value.query('select 1');
}lease accepts transient tokens only. Releasing a shared instance would affect every other holder.
Reaching a releasable transient any other way is rejected at build():
UnreleasableTransientError: 'Conn' is transient and declares a release, but 'Job' depends on it through
lazy(). A transient's release only runs through a lease — take lease(Conn) instead, or make 'Conn'
scoped so the scope can release it.lazy(Token) covers the case where a transient has nothing to release:
module().factory(Tracer, [lazy(Span)], (span) => ({
startSpan: () => span(), // a fresh Span per call
}));Circular dependencies
Where two services genuinely require each other, lazy defers one of the two:
module()
.factory(Orders, [lazy(Invoices)], (invoices) => ({
place: () => invoices().settle(), // invoices is () => Invoices
}))
.factory(Invoices, [Orders], (orders) => ({ settle: () => orders.place() }));Otherwise build() reports the cycle:
Circular dependency: A → B → A. Make one of them lazy — e.g. lazy(B) in the dependencies of 'A'.Troubleshooting
Property 'get' does not exist on type 'Rejected<"no provider for 'Logger'">'
Something needs Logger and nothing provides it. Several missing at once come as a union:
Rejected<"no provider for 'Logger'" | "no provider for 'Db'">.
Rejected<"'Session' is scoped — resolve it from container.scope(), not the root container">
Open a scope first: await using scope = app.scope().
Rejected<"'Logger' is not provided by this container">
Usually a token imported from the wrong file, or a module you forgot to .include().
Type 'Token<Db, …>' is not assignable to type 'Token<Logger, …>'
Two dependencies in the wrong order. Compare the list against the constructor.
The initializer of an 'await using' declaration must be…
One of your scoped services is built asynchronously, so app.scope() returns a promise here. Write
await using scope = await app.scope().
Errors from build()
| | |
| --- | --- |
| Circular dependency: A → B → A… | Add lazy() to one dependency. |
| 'A' (singleton) cannot depend on 'S' (scoped)… | Make A scoped too, or pass the short-lived value in as an argument. |
| Two different tokens are both named 'Db'… | Token names have to be unique. Thrown where you registered it. |
| Failed to construct 'Db'. | A provider threw; the original error is on .cause. |
| No provider for 'B'. Required by: A → B | Only reachable if the type checker was bypassed. |
Errors at runtime
| | |
| --- | --- |
| This scope has been released… | Something outlived its await using block. |
| 2 finalizers failed during release: 'Queue', 'Pool'… | Causes are on .failures. |
All of these extend TieDiError.
API reference
Tokens
| | |
| --- | --- |
| token<T>()('Name') | One instance per container. |
| token.scoped<T>()('Name') | One instance per scope. |
| token.transient<T>()('Name') | A new instance every time. Never released by the container. |
| multiToken<T>()('Name') | Many contributions, resolves to readonly T[]. Also multiToken.scoped. |
Dependencies
A dependency is a token, or a token in one of these:
| | |
| --- | --- |
| lazy(Token) | () => T. Breaks a cycle, or pulls a fresh transient. |
| optional(Token) | T \| undefined. Doesn't need to be provided. |
| lease(Token) | () => Lease<T>, transient only. Has .value, .release(), and works with await using. |
Modules
| | |
| --- | --- |
| module() | Start an empty module. |
| .class(Token, Impl, [deps]) | new Impl(...deps). |
| .factory(Token, [deps], fn) | fn(...deps). Return a promise for async. |
| .value(Token, value) | Use something you already have. Never released. |
| .resource(Token, [deps], { acquire, release }) | Build something that needs closing. |
| .assisted(Token, Impl, [deps]) | A factory; deps fill the leading parameters. |
| .contribute(MultiToken, [deps], fn) | Add one item to a list. |
| .include(module) | Merge another module. |
Containers and scopes
| | |
| --- | --- |
| build(module) | Check the wiring and construct. Promise<Container> if anything is async. |
| .get(Token) | Resolve. Never async. |
| .lease(Token) | Borrow a transient you release yourself. |
| .scope() | Open a scope. |
| .close() | Release everything, newest first. |
| .released | Whether it's been closed. |
Inspecting the graph
@tie-di/core/inspect is a separate entry point for tooling. Nothing it exports is reachable from
@tie-di/core, build keeps its single parameter, and Container gains no methods. Code from this
subpath is only bundled if it is imported; the emission points the core carries for it add 149 bytes
gzipped.
graphOf describes a module without constructing anything, which is enough to draw it:
import { graphOf } from '@tie-di/core/inspect';
graphOf(AppModule);
// {
// nodes: [
// { token: 'Config', lifetime: 'singleton', provider: 'value', releasable: false, dependsOn: [] },
// { token: 'Db', lifetime: 'singleton', provider: 'resource', releasable: true,
// dependsOn: [{ to: 'Config', kind: 'direct' }] },
// { token: 'Session', lifetime: 'scoped', provider: 'factory', releasable: false,
// dependsOn: [{ to: 'Db', kind: 'direct' }, { to: 'Metrics', kind: 'optional' }] },
// ],
// order: ['Config', 'Db', 'Session'],
// }kind distinguishes direct, lazy, optional and lease dependencies, and order is the sequence
services are constructed in. Verification runs as it does in build(), so an invalid module throws here
too.
inspect is build with an observer attached. It takes the same modules, runs the same checks, and returns
the same container, including the promise when the graph is async:
import { inspect } from '@tie-di/core/inspect';
const app = inspect(AppModule, (event) => console.log(event));
// scope-opened { scope: 0, parent: null }
// constructed { token: 'Config', lifetime: 'singleton', scope: 0 }
// resolved { token: 'Config', scope: 0 }
// constructed { token: 'Db', lifetime: 'singleton', scope: 0 }
// scope-opened { scope: 1, parent: 0 }
// constructed { token: 'Session', lifetime: 'scoped', scope: 1 }
// scope-closed { scope: 1 }
// released { token: 'Db', scope: 0 }
// scope-closed { scope: 0 }The events are scope-opened, scope-closed, constructed, resolved, construction-failed and
released. Scope ids are unique per process, so a tool watching several containers can tell them apart, and
parent reconstructs the scope tree. construction-failed and released carry an error.
Events carry no timestamps, since reading a clock means reaching for a platform global and the core has none. Stamp them in the handler. An observer that throws is ignored rather than allowed to break the container.
Requirements
TypeScript 5.2 or newer, and:
{
"compilerOptions": {
"strict": true,
"verbatimModuleSyntax": true,
"target": "ES2022",
"lib": ["ES2022", "ESNext.Disposable"]
}
}verbatimModuleSyntax catches import { Logger } where you meant import type { Logger }. Easy to get
wrong when a file exports an interface and a token under one name, and it pulls tie-di into your
implementation files.
No Node or DOM globals, and the published bundle has no imports. Tested on Node 20+.
moduleResolution must be bundler, node16 or nodenext to import
@tie-di/core/inspect. The legacy node setting predates subpath exports and
cannot resolve it; the main entry point works there regardless.
Design rationale is in docs/design.md.
License
MIT
