npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tie-di/core

v0.1.0

Published

Compile-time verified dependency injection for TypeScript. If it compiles, it resolves.

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] hello

Omitting 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/core is 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 fail

Scopes

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 here

Scopes 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 pool

Classes 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 close

An 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 throws ReleasedError.

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 async

build 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 order

Optional 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 call

The 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