Skip to content

Error handling ​

Errors are designed to tell you exactly what went wrong and what to do about it.

The three registers ​

Every diagnostic this tool raises is written in the same three registers, in the same order:

  1. What happened, in a sentence, in the words you would use. No type text, no paths, and no jargon where a plain word will do.
  2. The mechanism — the key, the contract, the file and line, the demanded and supplied types. Dense on purpose: it is the part no documentation page can supply, because it is about your workspace at this moment.
  3. The docs pointer — → docs: <url>, naming the page that articulates the rule.

The third register is what keeps the first short. An error does not have to teach the demand model inline when it can name the chapter that does, so the enumeration in a message is a list of names and the articulation lives at the link.

Pointers are resolved from a single map of diagnostic code → page, and a test in this repository resolves every one of them against the docs sources and (after npm run docs:build) against the rendered HTML. A heading rename that breaks a pointer fails the build rather than 404ing a reader. A code with no page yet simply prints no arrow — an absent pointer is honest; an invented one is not.

Terminal output is coloured when stdout is a TTY, and respects NO_COLOR and FORCE_COLOR. Piped or redirected output is plain, byte for byte — severity is always stated in words as well as in colour, so a warning in a CI log still reads as a warning. Colour never appears in --json.

Config errors are prefixed [ioc-config] — unknown contracts in registrations, duplicate defaults, key collisions. These fail at generation time before any files are written.

Discovery errors are prefixed [ioc] — missing return type annotations, contract sites that aren't named types, two contract declarations sharing a name, classes listing several implements entries or carrying a non-injectable constructor, duplicate registration keys, overlapping scan directories with conflicting scopes, and factories destructuring directly from IocGeneratedCradle (use named deps types instead). Discovery aggregates by category: one run reports every offending export, not the first.

Generated-reference errors are prefixed [ioc] and name the file, the line, the offending source text, why the form can't be supported, and the supported replacement. See Consuming generated types.

Demand-model errors are prefixed [ioc] and carry a bracketed code per offender. A deps property is one of exactly five declared things — a contract key, a Named<T> implementation key, a group key, an opener key, or an external — and these are the ways a property fails to be one of them. The preamble names the five and links to the demand model, which articulates them; each offender line carries its own code, unit, file, line and property name. All of them aggregate: one run lists every offending property.

codewhat it meansfix
named-marker-requiredThe property's name is an implementation registration key, demanded with a bare contract type. The site cannot be told apart from a contract-key demand or an external.Pick the one you mean. For the elected default, demand the contract key (authMiddleware: AuthMiddleware); for that implementation, write strictAuthMiddleware: Named<AuthMiddleware>. The error prints both spellings for your key.
named-contract-mismatchNamed<C> where the implementation's declared contract is not C. Identity is exact, never assignability.Write the implementation's own contract, which the error names, or demand an implementation of the contract you asked for.
named-on-contract-keyNamed<…> on a contract slot key. The slot resolves whichever implementation is elected, so "that specific implementation" is not something it can say.Drop the marker to demand the elected default, or name an implementation's own registration key.
named-on-group-keyNamed<…> on a group root key. A group key resolves the whole collection.Drop the marker, or demand a member's own registration key with Named<…>.
named-on-opener-keyNamed<…> on a scope-root opener key. An opener is emitted by generation, not registered by an implementation.Demand it by its emitted alias (openAuthRouterScope: OpenAuthRouterScope).
named-unknown-keyNamed<…> on a name no implementation — local or composed — is registered under.An unregistered key is an external; demand it by its plain type. Check the spelling against the registration key.
named-wrong-arityNamed written with anything other than exactly one type argument.Write Named<TContract>.
grouped-member-demandThe property names a member of a configured group, or a grouped contract's would-be contract key. Grouped ⇒ group-only: members have no cradle keys and the contract has no contract key. All four spellings — Named<MemberContract>, Named<GroupBase>, the bare member key, and the absent contract key — land here rather than on the strict-identity or unknown-key texts, because the problem is the family and not which contract was named.Consume the group. The error names the group's key, and for a record group the member property (channels.emailChannel). If you need keyed access to a member, the group's kind is the lever — or the member does not belong in the group. See Consumer-divergent group consumption.

A contract that elects no default has no contract key at all, and the named-marker-required message says so rather than offering a spelling that would not resolve.

[ioc] 1 deps property does not name any of the five things a dependency can be (contract key, `Named<TContract>` implementation key, group key, opener key, external):
→ docs: https://reharik.github.io/ioc-manifest/concepts/conventions#demanding-a-dependency-the-five-things-a-deps-property-can-be
  - [named-marker-required] Class "ArchiveStorage" at ArchiveStorage.ts:18 property "localStorage" is the registration key of implementation "localStorage" (contract "Storage", in this package), demanded without saying so. For the elected default, demand the contract key `storage: Storage`; for this specific implementation, write `localStorage: Named<Storage>`.

ioc validate gives the same guidance from the artifact side. An app whose committed IocExternals predates a library's regrouping demands a key nothing supplies any more — which for a grouped member is the rule working, not drift, since a grouped contract claims no individual cradle key. The [externals] issue for such a key names the group and tells you to consume it, never to register a shadow factory in this app, and adds the hint to re-run ioc generate here. All three spellings a stale file can carry are recognized: the member's registration key, its contract key, and the group base's would-be slot key.

Membership is read from composed manifests too, not only from this package's ioc.config.ts. A group declared in a library states its roots in the manifest it publishes, so a demand in the composing app for one of that library's members lands on grouped-member-demand — naming the library's group — rather than on named-marker-required, whose advice the group law forbids.

An offender line repeats a pointer only when its own code points somewhere the preamble does not — grouped-member-demand links to the group law, because the problem there is the family and not the spelling.

File paths in this family are scan-directory relative, the same as the other demand-analysis errors.

Discovery warnings are prefixed [ioc] and never block generation. They cover units that matched a trigger but couldn't be used, concrete classes that inherit a contract without declaring implements, abstract classes declaring a contract nothing concrete registers, and class file names that would have keyed differently under Awilix loadModules. ioc inspect --discovery shows the same findings per export, with a categorized reason.

Dependency-key coverage is reported as one block, prefixed [ioc], naming every accepted unit whose deps parameter this generation could not read — file, line, export, the shape (non-destructured-parameter, defaulted-parameter, array-binding-parameter, rest-element, nested-binding, computed-property, callable-parameter-type, unresolvable-signature), the parameter as written, why that shape hides the keys, and the fix for that shape specifically. These units register and resolve normally; what they lack is a record of what they demand, so the package cannot claim dependencyKeysComplete. A warning by default — the code works, and failing a build over working code teaches teams to switch the check off — promoted to an error by dependencyKeyCoverage: "error", silenced by "off". The token is withheld either way. See dependency-key coverage.

Group-lifetime errors are prefixed [ioc] and carry a bracketed code per offender. A group is a family whose members are handed out interchangeably, so the family ranks one lifetime and the base is where it is declared. These aggregate too.

codewhat it meansfix
group-lifetime-on-memberA grouped member's contract declares a lifetimeMarkers interface that the group's base does not carry.Move the marker to the base, so the whole family ranks it — or take the contract out of the group. The error names both.
group-lifetime-config-overrideregistrations[Member][impl].lifetime is set for a grouped member.Set the family's lifetime by putting a marker on the base instead.

A member that redundantly restates the base's own marker is not an error: it is indistinguishable from inheriting it, and the base owns the lifetime either way.

Lifetime-inversion errors carry the code [lifetime-inversion] and aggregate. The sentence states the floor rule, the pointer links the chapter, each offender names the consumer, the dependency, both lifetimes and what the combination does at runtime, and one fix line closes the run. singleton → scoped is an error; the other inversions are warnings, printed one at a time with their own pointer.

A dependency registered by a composed package is attributed — 'mediaItemReadRepository' (scoped, composed package "@app/media-core") — because the fix is written in this repository while the lifetime being complained about is not. Anything unannotated is this package's own.

The same family carries one warning that is not an inversion: a group member the check reached and could not rank prints as that edge is UNRANKED, not cleared, naming the member, its registration key, and which of three reasons applies (the composing app supplies the key at bootstrap with no declared lifetime; a composed group root names a member no manifest this run read registers; nothing local or composed carries it). It is disclosure, not a verdict — an unranked edge is not a cleared one — and it is raised for non-transient consumers only, since a transient consumer outlives nothing and no lifetime the member turned out to have could have produced a finding. See Across a composed boundary.

Scope-root verification findings carry the codes lbv_missing_key, lbv_type_mismatch, lbv_unused_key and lbv_composed_blind_spot, and each links to the matching section of Scope roots.

Composition errors are prefixed by category ([externals], [same-key-conflict], [group-base-type], etc.) and emitted by the composition suite, which app-mode ioc generate runs before writing anything and ioc validate runs without regenerating. Both aggregate: a failing run reports every issue at once, not just the first. In generate they arrive under [ioc] App-mode generation refused: … and nothing is written.

Each issue renders as the category tag, the plain-language summary, the mechanism lines beneath it, a suggested fix, and the docs pointer:

[externals] Unsatisfied: nothing supplies "logger", which @apps/api expects the container to already have.
  key:       "logger"  demanded by @apps/api
  demanded:  Logger
  No composed manifest offers this key in its IocGeneratedCradle.
  Suggested fix: Register a factory for Logger under key "logger" in this app, or compose another manifest that supplies it.
  → docs: https://reharik.github.io/ioc-manifest/monorepo/composition#externals

ioc validate --json carries the same record — category, severity, summary, details, suggestedFix and docUrl — with no colour and no layout, under the document's issues key (see the 4.0 envelope change).

[registry-integrity] is the one that gates the others: before comparing types, the suite checks that the generated registry-types files it reads types out of actually compile. A name that does not resolve there becomes an error type, and comparisons against an error type pass regardless of what they are asked — so a broken file is reported as an error, and the comparisons that read from it are skipped and listed as skipped rather than reported satisfied. The usual cause is generated output that predates a source change; re-run ioc generate (or regenerate the composed package named in the issue). Errors that survive regeneration mean the file was emitted broken — that is an ioc-manifest bug worth reporting.

Runtime resolution errors use IocResolutionError with structured dependency chains:

[ioc] Cannot build AlbumService using implementation albumService.

Resolution chain:
  AlbumService (albumService) [services/buildAlbumService.ts]
    -> MediaStorage (s3MediaStorage) [services/buildS3MediaStorage.ts]
      -> S3Client ✖ no registered implementation

Missing dependencies, cyclic references, lifetime violations, and factory exceptions are all caught and reported with the full resolution path.

A cycle that runs through a group names the hop and the read that closed it. Group member slots resolve lazily, so resolving a group can no longer be the thing that builds a member — what is left is a unit reading a member property while it is itself still under construction:

[ioc] Cannot resolve group "writeServices".

Resolution chain:
  writeServices (group)
    -> AddComment (addComment) [addComment.ts]
      -> writeServices (group)
        -> AddComment (addComment) [addComment.ts] ✖ cyclic dependency detected

A member of group "writeServices" was read during construction.
  Reading writeServices.addComment builds that member right there, and building it led
  back to the unit that was still being constructed.

Read group members at CALL time — inside the function or method you return — rather than at the
top level of the factory body. Holding the group itself costs nothing: the group value is inert
until a member property is read, so demanding or destructuring it constructs no members.

See Members resolve when you read them.

A lifetime leak caught by Awilix strict mode — on by default since registerIocFromManifest enables it — arrives through the same machinery, as a lifetime failure with the manifest-aware chain:

[ioc] Cannot build Holder using implementation holder.

Resolution chain:
  Holder (holder) [holder.ts]
    -> Ticket (ticket) [ticket.ts] ✖ dependency lifetime is shorter than an ancestor (strict mode)

If generation only warned about this edge, that is expected and documented: our severity model ranks singleton → transient as a warning and strict does not. See The runtime is strict for the reconciliation and the { strict: false } opt-out.

A missing scope-provided value surfaces here too: resolving a service whose scope value wasn't registered produces a no registered implementation leaf for that key. If you see this for a key declared in scopeProvided, the fix is to register it onto the child scope before resolving — not to add a factory.


Released under the MIT License.