An error hierarchy, instead of throw new Error(...)
The easiest way to signal a failure in a small library is throw new Error('something went wrong'). TinyDI instead has a small class hierarchy: an abstract ContainerError base, and three concrete subclasses — RegistrationError, ResolutionError, CircularDependencyError. The interesting part of this story is not the hierarchy itself, it is a design decision the spec implied but never spelled out.
The hierarchy
export abstract class ContainerError extends Error {
protected constructor(
message: string,
public readonly token?: Token<unknown>,
) {
super(message);
this.name = new.target.name;
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class RegistrationError extends ContainerError { /* ... */ }
export class ResolutionError extends ContainerError { /* ... */ }
export class CircularDependencyError extends ContainerError { /* ... */ }
ContainerError carries the Token involved in the failure (when there is one), so a catch-all handler can log which token caused the problem without caring which concrete subclass it caught:
try {
container.resolve(SomeToken);
} catch (error) {
if (error instanceof ContainerError) {
console.error(`DI failure for "${error.token?.description}": ${error.message}`);
}
}
The decision the spec left implicit
The task spec named RegistrationError as part of the expected hierarchy, but it never said exactly when it should fire. The obvious design question: what happens when you register the same token twice? Two reasonable answers exist — silently let the second registration win ("last write wins", the more permissive option), or throw. TinyDI throws:
private assertNotRegistered(token: Token<unknown>): void {
if (this.registrations.has(token.symbol)) {
throw new RegistrationError(
token,
`Token "${token.description}" is already registered. ` +
'Call remove() first to replace it, or clear() to reset the container.',
);
}
}
This was a deliberate choice, not the only reasonable one: a class of error the spec had declared but not described still needed a design decision to actually be honored as a public contract, rather than left to whatever the implementation happened to do first.
Silent overwrite is a footgun in a DI container. Accidentally registering the same token twice — once in application code, once in a test setup that forgot to clean up, most commonly — is exactly the kind of bug that is loud and obvious the moment it happens with an explicit throw, and silent and hard to trace if the second registration just wins. Throwing costs a little convenience; letting it slide costs a debugging session weeks later, tracing why the wrong implementation is somehow the one that got resolved.
The escape hatch
Because overwriting throws, swapping an implementation — most commonly in tests — requires an explicit container.remove(token) (or container.clear()) before registering again. That extra step is the cost of this decision; it also means "I meant to replace this" is always visible in the code as its own line, not implicit in a second registration call.
What this means for consumers
A four-class hierarchy over a flat Error is a small amount of extra code, but it lets consumers branch on failure kind when they need to (e.g. treat a ResolutionError for an optional service differently from a RegistrationError surfaced during app bootstrap) while still being able to catch everything DI-related with one instanceof ContainerError check. CircularDependencyError, the fourth subclass, gets its own post — the interesting part there was not the class itself but the message format. See the API Reference for the full error surface.