Skip to content

Getting started ​

Install ​

sh
npm i haywire
sh
pnpm add haywire
sh
yarn add haywire

TIP

Haywire is ESM only, and requires Node.js 24 or later. From CommonJS, load it with a dynamic import: const { bind } = await import('haywire');

Haywire runs in plain JavaScript, but its checks happen in TypeScript. They are tested with strict and exactOptionalPropertyTypes enabled, so turn both on.

Wiring an app takes four steps: name each dependency with an id, bind each id to how it is built, collect the bindings in a module, and turn the module into a container that builds instances on request.

Ids ​

Types don't exist at runtime, so Haywire needs a value to stand for each one. A class is its own id. For anything else, such as an interface or a primitive, create one with identifier<T>():

ts
import { 
identifier
} from 'haywire';
interface Logger {
info
(
message
: string): void;
} const
loggerId
=
identifier
<Logger>();
const
dbUrlId
=
identifier
<string>().
named
('dbUrl');
const
dbPasswordId
=
identifier
<string>().
named
('dbPassword');

named() tells apart ids of the same type. It takes a string literal, or a unique symbol when a string might collide.

Create each id once

identifier<T>() returns a new id every time, but TypeScript can't tell two identifier<number>() calls apart. Create each id once, and import it wherever it is needed. identifier(SomeClass) always returns the same id.

Ids are modified with nullable(), undefinable() and more, which change the type they stand for. See the HaywireId reference.

Bindings ​

bind(id) starts a binding, which tells Haywire how to build a value for the id. List its dependencies, then a provider that receives them in the same order:

ts
class 
Database
{
constructor( private readonly
logger
: Logger,
private readonly
url
: string
) {} } const
databaseBinding
=
bind
(
Database
)
.
withDependencies
([
loggerId
,
dbUrlId
])
.
withProvider
((
logger
,
url
) => new
Database
(
logger
,
url
));
url: string

When the id is a class whose constructor takes the dependencies in order, withConstructorProvider() calls it for you:

ts
const 
databaseBinding
=
bind
(
Database
)
.
withDependencies
([
loggerId
,
dbUrlId
])
.
withConstructorProvider
();

Dependencies are checked against the provider's parameters, in either order, so passing the wrong id is a compile error:

ts
bind
(
Database
)
.
withConstructorProvider
()
.
withDependencies
([dbUrlId, loggerId]);
Argument of type '[HaywireId<string, null, "dbUrl", false, false, false, false, false>, HaywireId<Logger, null, null, false, false, false, false, false>]' is not assignable to parameter of type '{ error: "Error: the dependencies don't match the provider's parameters, in type or order."; } & [HaywireId<string, null, "dbUrl", false, false, false, false, false>, HaywireId<...>]'. Property 'error' is missing in type '[HaywireId<string, null, "dbUrl", false, false, false, false, false>, HaywireId<Logger, null, null, false, false, false, false, false>]' but required in type '{ error: "Error: the dependencies don't match the provider's parameters, in type or order."; }'.

Values with no dependencies have shorthands:

ts
// Called to build each value
bind
(
loggerId
).
withFactory
(() =>
console
);
// Calls the class's constructor with no arguments
bind
(
Clock
).
withConstructorFactory
();
// A value that already exists
bind
(
dbUrlId
).
withInstance
('postgres://localhost/app');

Every binding method returns a new binding, so a binding can be shared and modified safely. A common modification is its scope: by default, a new value is built for every dependent.

Modules ​

A module collects bindings. Bindings can be added in any order, and modules merged together:

ts
const 
databaseModule
=
createModule
(
bind
(
Database
).
withDependencies
([
loggerId
,
dbUrlId
]).
withConstructorProvider
()
).
addBinding
(
bind
(
loggerId
).
withFactory
(() =>
console
));
const
envModule
=
createModule
(
bind
(
dbUrlId
).
withInstance
('postgres://localhost/app'));
const
appModule
=
databaseModule
.
mergeModule
(
envModule
);

A Haywire module is a bundle of bindings, as in Guice, not an ES module. Like bindings, modules are immutable: addBinding() and mergeModule() return a new module.

Each id gets exactly one binding, so adding a second is a compile error (and throws at runtime):

ts
createModule
(
bind
(
dbUrlId
).
withInstance
('postgres://localhost/app'))
.
addBinding
(bind(dbUrlId).withInstance('postgres://localhost/test'));
Argument of type 'Binding<OutputHaywireId<HaywireId<string, null, "dbUrl", false, false, false, false, false>>, [], false>' is not assignable to parameter of type '{ error: "Error: an output is already bound. Use replaceBinding() on a module to swap it."; } & Binding<OutputHaywireId<HaywireId<string, null, "dbUrl", false, false, false, false, false>>, [], false>'. Property 'error' is missing in type 'Binding<OutputHaywireId<HaywireId<string, null, "dbUrl", false, false, false, false, false>>, [], false>' but required in type '{ error: "Error: an output is already bound. Use replaceBinding() on a module to swap it."; }'.

To swap a binding, for example in a test, use replaceBinding(). For ids that collect many bindings, see Lists.

Containers ​

A module can be missing bindings, so it can be split up and combined freely. Once every dependency is bound, createContainer() turns it into a container, and get() builds instances:

ts
const 
container
=
createContainer
(
databaseModule
.
mergeModule
(
envModule
));
const
database
=
container
.
get
(
Database
);
const
dbUrl
=
container
.
get
(
dbUrlId
);
const dbUrl: string

A module with an unbound dependency can't become a container:

ts
// Forgot envModule
createContainer
(databaseModule);
No overload matches this call. Overload 1 of 2, '(this: void, factory: GenericContainerFactory): SyncContainer<any> | Container<any>', gave the following error. Argument of type 'Module<[NonExtendable<Logger, null, null, false, false, false>] | [NonExtendable<Database, typeof Database, null, false, false, false>] | ... 5 more ... | [...], never, [...] | [...], false>' is not assignable to parameter of type 'GenericContainerFactory'. Property '#private' in type 'Module' refers to a different member that cannot be accessed from within type 'ContainerFactory'. Overload 2 of 2, '(this: void, mod: { error: "Error: some dependencies have no binding. Add bindings for them before creating a container."; } & Module<[NonExtendable<Logger, null, null, false, false, false>] | ... 6 more ... | [...], never, [...] | [...], false>, options?: ContainerOptions | undefined): SyncContainer<...>', gave the following error. Argument of type 'Module<[NonExtendable<Logger, null, null, false, false, false>] | [NonExtendable<Database, typeof Database, null, false, false, false>] | ... 5 more ... | [...], never, [...] | [...], false>' is not assignable to parameter of type '{ error: "Error: some dependencies have no binding. Add bindings for them before creating a container."; } & Module<[NonExtendable<Logger, null, null, false, false, false>] | ... 6 more ... | [...], never, [...] | [...], false>'. Property 'error' is missing in type 'Module<[NonExtendable<Logger, null, null, false, false, false>] | [NonExtendable<Database, typeof Database, null, false, false, false>] | ... 5 more ... | [...], never, [...] | [...], false>' but required in type '{ error: "Error: some dependencies have no binding. Add bindings for them before creating a container."; }'.

And requesting an id the container doesn't bind is an error too:

ts
const 
container
=
createContainer
(
createModule
(
bind
(
dbUrlId
).
withInstance
('postgres://localhost/app'))
);
container
.
get
(dbPasswordId);
Argument of type 'HaywireId<string, null, "dbPassword", false, false, false, false, false>' is not assignable to parameter of type 'HaywireId<string, null, "dbPassword", false, false, false, false, false> & { error: "Error: the container has no binding for this id."; }'. Property 'error' is missing in type 'HaywireId<string, null, "dbPassword", false, false, false, false, false>' but required in type '{ error: "Error: the container has no binding for this id."; }'.

Async values ​

Some values can only be built asynchronously, such as a secret loaded from a remote store. Bind them with withAsyncFactory() or withAsyncProvider(). Dependents don't change: they receive the resolved value.

A container with any async binding only has getAsync():

ts
const 
container
=
createContainer
(
createModule
(
bind
(
dbPasswordId
).
withAsyncFactory
(async () =>
loadSecret
('db-password')))
); const
password
= await
container
.
getAsync
(
dbPasswordId
);
container
.get(
dbPasswordId
);
Property 'get' does not exist on type 'Container<[NonExtendable<string, null, "dbPassword", false, false, false>] | [NonExtendable<string, null, "dbPassword", false, false, true>] | [...] | [...]>'.

Every container has getAsync(), so code that receives a container of either kind can use it, or narrow with isSyncContainer().

Check the container in a test ​

Some mistakes can't be caught by types: a circular dependency without a deferred side, or a sync supplier of an async value. container.check() finds them without building anything, so call it in a test:

ts
export const 
container
=
createContainer
(
createModule
(
bind
(
dbUrlId
).
withInstance
('postgres://localhost/app'))
); // In a test
container
.
check
();

See Testing for more.

Next steps ​