Async construction and disposal lifecycle primitives — the missing symbol for constructing asynchronous things.
yarn add async-init
# or
npm install async-initSymbol.asyncDispose gave JavaScript a standard teardown hook. Nothing gave it the other half. A class that needs to open a connection, warm a cache, or await a handshake after new has no standard place to do it — new is synchronous, full stop.
Most codebases patch around this with a ready() method the caller has to remember to await, or a factory function that duplicates the class's own fields. Both work until you need composition: an await using block, an AsyncDisposableStack, a container that resolves ten services in dependency order. Ad-hoc ready() conventions don't compose — every consumer has to know your particular flavor of "not ready yet," and a container has no symbol to detect it by.
async-init adds that symbol. Three layers, each usable on its own:
- The protocol — a symbol and two functions. No state, no dependencies.
Service<T>— a lazy, awaitable handle around a resolver, for when you want deferred construction without a container.ServiceRegistry— an optional token-keyed container with dependency injection, child scopes, andawait usingteardown.
asyncInit is the async complement to Symbol.asyncDispose: a symbol-keyed method a class implements to run setup after its constructor returns.
import { asyncInit, construct, init } from "async-init"
class Database {
public connected = false
public async [asyncInit](): Promise<void> {
this.connected = true
}
public async [Symbol.asyncDispose](): Promise<void> {
this.connected = false
}
}
const db = await construct(Database) // new Database() + await db[asyncInit]()
db.connected // trueconstruct is new plus init in one call, with constructor arguments fully typed against the class. Use init directly when you already have an instance — a new-then-init two-step, or a base class initializing a subclass instance passed in from elsewhere.
Two guards round out the protocol: isAsyncInitializable and isAsyncDisposable walk an object's prototype chain (not just its own properties), so a subclass inheriting [Symbol.asyncDispose] from a base class is still detected correctly. markDisposed / isDisposed mark an object disposed exactly once. Service uses them on the instance it resolves: before calling [Symbol.asyncDispose] it checks isDisposed and skips instances already marked, and only calls markDisposed after that call resolves successfully — so a dispose that throws leaves the instance unmarked and retryable. ServiceRegistry does not use markDisposed at all; it tracks its own disposed state with a private flag, which is what the E_DISPOSED guards on register, get, and createChild check.
Service<T> wraps a resolver — an instance, a factory, or a zero-arg constructor — behind a handle that's awaitable and disposable. Nothing runs until the handle is awaited; the resolution is memoized, so concurrent awaiters share one in-flight promise instead of racing separate constructions.
import { Service } from "async-init"
class Greeter {
public planet = "world"
public async [Symbol.asyncDispose](): Promise<void> {}
public greet(name: string): string {
return `hello ${name} of ${this.planet}`
}
}
const service = new Service(Greeter)
const greeter = await service // resolves and memoizes
greeter.greet("teffen") // 'hello teffen of world'If the underlying instance implements the lifecycle protocol, Service calls [asyncInit] on it during resolution and forwards [Symbol.asyncDispose] to it on disposal. A plain instance with neither still works — Service degrades to a lazy memoizing wrapper.
proxyService goes one step further: it wraps a Service<T> in a proxy that exposes T's own members directly, so you can call a method before the service has resolved — the call awaits resolution first, then dispatches. Methods return promises; plain properties become zero-argument thunks, since a property can't be read synchronously off something that isn't built yet.
import { proxyService } from "async-init"
const proxied = proxyService(new Service(Greeter))
await proxied.greet("teffen") // 'hello teffen of world' — resolves first, then calls
await proxied.planet() // 'world' — a thunk, since `planet` isn't a methodServiceRegistry is a token-keyed container over Service<T> handles. It's entirely optional — every layer above works without it — but it's where dependency injection and scoped teardown live.
Tokens bind a runtime key to a phantom type, so registry.get(token) comes back typed without a manual cast:
import { createToken, ServiceRegistry } from "async-init"
class Logger {
public readonly lines: string[] = []
public log(line: string): void {
this.lines.push(line)
}
public async [Symbol.asyncDispose](): Promise<void> {}
}
const ILogger = createToken<Logger>("logger")
await using registry = new ServiceRegistry()
registry.register(ILogger, Logger)
const logger = await registry.get(ILogger)A class can declare its own dependencies as a static dependencies tuple of tokens. This is the erasable-TS answer to parameter-decorator DI — decorators need emitDecoratorMetadata, which needs a build step, which is exactly what this package avoids. The registry resolves and initializes every declared dependency, in parallel, before calling the constructor — so the constructor body can use them as plain, already-ready values:
class Indexer {
public static readonly dependencies = [ILogger] as const
constructor(logger: Logger) {
// The dependency is fully resolved and initialized here.
logger.log("indexer constructed")
}
public async [Symbol.asyncDispose](): Promise<void> {}
}
const IIndexer = createToken<Indexer>("indexer")
await using registry = new ServiceRegistry()
registry.register(ILogger, Logger)
registry.register(IIndexer, Indexer)
const indexer = await registry.get(IIndexer)registry.createChild() opens a scope that falls back to its parent on lookup, lets a child registration shadow a parent one, and chains abort signals downward. Disposing a registry disposes its children first (LIFO), then its own services, through an internal AsyncDisposableStack — which is also what makes await using registry do the right thing at scope exit.
class Value {
public readonly value: string
constructor(value: string) {
this.value = value
}
public async [Symbol.asyncDispose](): Promise<void> {}
}
const IValue = createToken<Value>("value")
await using parent = new ServiceRegistry()
parent.register(IValue, new Value("from-parent"))
const child = parent.createChild()
// Lookups fall back to the parent chain...
const shared = await child.get(IValue)
// ...and a child registration shadows the parent's.
const scoped = parent.createChild()
scoped.register(IValue, new Value("from-child"))
const fromChild = await scoped.get(IValue)
const fromParent = await parent.get(IValue)
fromChild.value // 'from-child' — shadowed
fromParent.value // 'from-parent' — untouchedRunnable, self-contained examples for every layer live in examples/ — from the bare protocol through dependency injection and per-request child scopes. Each runs directly from a clone with node examples/<file>.ts, no build step.
- Cycles between injectable constructors are caught, not all cycles are. The registry walks the statically declared
dependenciestuples before resolving, and throwsE_DEPENDENCY_CYCLEwith the full path (a → b → a) if it finds one. That walk only sees tokens declared independencies— a cycle routed through an opaque factory resolver (a plain function that calls back into the registry itself) isn't visible to static analysis and will deadlock instead of throwing. Break those by depending on theServicehandle rather than the resolved value, and awaiting it after your own construction finishes, not during it. - Async-only. There is no synchronous
Disposablecounterpart in v1 —ServiceandServiceRegistryare bothAsyncDisposable, andasyncInithas no sync equivalent. A piece of your system that never needs to await construction or teardown doesn't need this package. - Proxy access to unknown or symbol-keyed properties returns an async wrapper, not the real value.
proxyServicecan't tell a real member from an incidental one —Symbol.iterator, Node'sutil.inspect.custom, or any other symbol-keyed access on a proxied handle comes back as a function that resolves the service first, same as any other property. Platform code that expects a synchronous return (iteration protocols,console.logformatting) gets aPromiseinstead and breaks. The same applies if your own class declares a property or method namedthen,resolve, orinstance— those names are already claimed byService.prototypeand win over your instance's own members when accessed through the proxy. - Self-guarding your own class's dispose trades away retry-on-failure. You don't need the
markDisposedidiom (if (!markDisposed(this)) return) just to survive aServicehandle — the handle already provides that idempotence, mark-after-success, so your cleanup runs exactly once no matter how many times the handle is disposed. Add the self-guard only if your dispose method might also be invoked directly, more than once, outside of any handle — or if one pre-built instance is wrapped by two separateServicehandles that could dispose concurrently (sequential double-dispose is already safe; the concurrent case is the one race the handle's own guard can't close). If you do, know the cost: marking as the first line means a throw partway through your cleanup still leaves the instance marked disposed, so a later direct retry silently no-ops instead of re-running. Mark disposed after your cleanup actually finishes if you want both direct-call idempotence and a failed dispose to stay retryable.
async-init compiles under isolatedDeclarations — every exported value, function, and class member carries an explicit type annotation, so declaration emit never depends on cross-file inference. It also compiles under erasableSyntaxOnly: no enum, no constructor parameter properties, nothing that requires more than stripping types to run. That's why the tests above use long-form field assignment instead of parameter properties, and why LifecycleErrorCode is a const object with a derived type instead of an enum.
The practical effect: the package runs directly under node's built-in type stripping. There's no build step between cloning the source and running it.
async-init is licensed under the MIT license. See LICENSE.md for the full text.