- Book: TypeScript Essentials
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You hit fifty services. Your main.ts is now a wall of new. Forty-seven lines of construction, half of them passing the same logger and config into the next thing. A PR swaps the Postgres pool for a read replica in three places, misses the fourth, and a queue worker keeps writing to the primary until someone notices.
The instinct, watching that code review burn down, is to reach for a container. Pull in tsyringe or inversify, mark every class @injectable(), let the framework wire it. Tag the bootstrap as solved.
The instinct names a real problem and overshoots the fix. The wall of new is real. The decorator container is overkill for almost every TypeScript service shipped today. Stage-3 decorators landed in TypeScript 5.0 with different semantics than the legacy ones; the libraries you reach for are still picking sides. You can sidestep the whole question with sixty lines of TypeScript and a clear rule about when you actually need more.
The wall of new
Here is the shape that triggers the reach for a container. It looks innocent at first.
// main.ts
import { Pool } from "pg";
import { Logger } from "./logger";
import { Config } from "./config";
import { OrderRepository } from "./orders/repository";
import { StripeGateway } from "./payments/stripe";
import { OrderService } from "./orders/service";
import { OrderHandler } from "./orders/handler";
import { Mailer } from "./mail/ses";
const config = Config.fromEnv();
const logger = new Logger(config.logLevel);
const pool = new Pool({ connectionString: config.databaseUrl });
const orderRepo = new OrderRepository(pool, logger);
const stripe = new StripeGateway(config.stripeKey, logger);
const mailer = new Mailer(config.awsRegion, logger);
const orders = new OrderService(orderRepo, stripe, mailer, logger);
const orderHandler = new OrderHandler(orders, logger);
Five services. It reads top-to-bottom. Add ten more services and ten more dependencies and the file works fine. Add fifty and the constructor positions start mattering: which logger argument, which order, which one is the audit logger versus the request logger. People copy a line, swap one argument, miss another.
That is the smell. Not the line count, the positional fragility. Every constructor takes things, and the call site has to remember the order.
The four-line container most teams need
Before reaching for a library, try this. It is not a container in the framework sense. It is a container in the dictionary sense: a thing that holds other things.
// services.ts
export const services = {
config: Config.fromEnv(),
get logger() { return new Logger(this.config.logLevel); },
get pool() { return new Pool({ connectionString: this.config.databaseUrl }); },
};
Four lines, ignoring the imports. services.logger resolves on access. Add memoization the moment you want singletons:
// services.ts
import { Pool } from "pg";
import { Logger } from "./logger";
import { Config } from "./config";
const config = Config.fromEnv();
const logger = new Logger(config.logLevel);
const pool = new Pool({ connectionString: config.databaseUrl });
export const services = { config, logger, pool } as const;
Now OrderService takes services (or just the slice it needs) and the call site stops being positional. You name what you want.
// orders/service.ts
import type { services } from "../services";
type Deps = Pick<typeof services, "logger"> & {
repo: OrderRepository;
payments: PaymentGateway;
};
export class OrderService {
constructor(private readonly deps: Deps) {}
async place(input: PlaceInput): Promise<Order> {
this.deps.logger.info("placing order", { id: input.id });
const chargeId = await this.deps.payments.charge(input.amountCents, input.token);
const order: Order = { id: input.id, chargeId, amountCents: input.amountCents };
await this.deps.repo.save(order);
return order;
}
}
The whole pattern is: collect dependencies in an object, pass the object, destructure the slice you need, let TypeScript's structural typing do the wiring check at compile time. There is nothing magic in here. There is no library. The construction order is still explicit, but the call sites are no longer fragile.
This is the version most TypeScript services should land at and stop. If you read the code your colleagues ship at companies that do not have a framework opinion baked in, this is what you find: a services.ts or composition.ts or container.ts with a plain object, sometimes wrapped in a function, exported once, imported wherever.
The smell test: can a new contributor read this file in thirty seconds and know what the program is built from? If yes, you do not need more.
When the object grows up: a typed Container class
The object-literal works until you want a few things it cannot give you cleanly.
- Lazy construction (the
Poolshould not connect until something asks). - Per-request scoping (each HTTP request gets its own correlation-ID logger).
- Replacement for tests (swap the real Stripe gateway for a stub without rebuilding the file).
At that point, sixty lines of TypeScript get you a typed Container that does all three without a decorator in sight. The trick is branded tokens: each registration key carries the type of what is registered, so resolve returns the right type with no casts.
// container.ts
declare const tokenBrand: unique symbol;
export type Token<T> = symbol & { readonly [tokenBrand]: T };
export const token = <T>(description: string): Token<T> =>
Symbol(description) as Token<T>;
type Lifetime = "singleton" | "transient";
type Factory<T> = (c: Container) => T;
interface Registration<T> {
factory: Factory<T>;
lifetime: Lifetime;
instance?: T;
}
Tokens carry their own type, registrations carry a factory and a lifetime. The container itself is a thin map plus three methods.
export class Container {
private readonly registry = new Map<symbol, Registration<unknown>>();
register<T>(t: Token<T>, factory: Factory<T>, lifetime: Lifetime = "singleton"): this {
this.registry.set(t, { factory, lifetime });
return this;
}
resolve<T>(t: Token<T>): T {
const reg = this.registry.get(t) as Registration<T> | undefined;
if (!reg) throw new Error(`No registration for token ${t.description ?? "<anonymous>"}`);
if (reg.lifetime === "transient") return reg.factory(this);
if (reg.instance === undefined) reg.instance = reg.factory(this);
return reg.instance;
}
child(): Container {
const c = new Container();
for (const [key, reg] of this.registry) {
c.registry.set(key, { factory: reg.factory, lifetime: reg.lifetime });
}
return c;
}
}
That is the whole thing. Sixty lines, give or take a brace. The type machinery lives in two places: the Token<T> brand and the way resolve<T> reads its return type from the token. No reflect-metadata, no decorators, no parameter inspection.
Use it like this:
// tokens.ts
import { token } from "./container";
import type { Logger } from "./logger";
import type { Pool } from "pg";
import type { OrderRepository } from "./orders/repository";
import type { PaymentGateway } from "./payments/gateway";
import type { OrderService } from "./orders/service";
export const TLogger = token<Logger>("Logger");
export const TPool = token<Pool>("Pool");
export const TOrderRepo = token<OrderRepository>("OrderRepository");
export const TPayments = token<PaymentGateway>("PaymentGateway");
export const TOrderService = token<OrderService>("OrderService");
// composition.ts
import { Container } from "./container";
import {
TLogger, TPool, TOrderRepo, TPayments, TOrderService,
} from "./tokens";
import { Logger } from "./logger";
import { Pool } from "pg";
import { OrderRepository } from "./orders/repository";
import { StripeGateway } from "./payments/stripe";
import { OrderService } from "./orders/service";
import { Config } from "./config";
export function buildContainer(config = Config.fromEnv()): Container {
const c = new Container();
c.register(TLogger, () => new Logger(config.logLevel));
c.register(TPool, () => new Pool({ connectionString: config.databaseUrl }));
c.register(TOrderRepo, (c) => new OrderRepository(c.resolve(TPool), c.resolve(TLogger)));
c.register(TPayments, (c) => new StripeGateway(config.stripeKey, c.resolve(TLogger)));
c.register(TOrderService, (c) => new OrderService({
repo: c.resolve(TOrderRepo),
payments: c.resolve(TPayments),
logger: c.resolve(TLogger),
}));
return c;
}
// main.ts
import { buildContainer } from "./composition";
import { TOrderService, TLogger } from "./tokens";
const c = buildContainer();
const orders = c.resolve(TOrderService);
const logger = c.resolve(TLogger);
logger.info("boot complete");
Three things this small container gives you that the object-literal version does not.
Lazy construction. Nothing is built until you call resolve. The Pool does not open a TCP socket at import time. Your test files that touch one tiny module do not pay a thirty-millisecond startup cost on every run.
Lifetime control. register(TLogger, factory, "transient") and you get a fresh logger per resolution. Useful for request-scoped loggers that carry a correlation ID. The default is singleton, which is what most services want.
Test replacement without monkey-patching. Build the container, override one registration, resolve. The override is a one-liner.
const c = buildContainer();
c.register(TPayments, () => new FakeGateway()); // last write wins
const orders = c.resolve(TOrderService);
For per-request scoping, child() clones the registry and lets you re-register a few tokens (the request logger, the auth context) without touching the parent. Each request gets its own child, the parent keeps its singletons.
Singleton versus transient: pick on purpose
The two-lifetime model is enough for almost every service. Singleton is the default for anything expensive, stateful, or wrapping a connection pool — logger, config, database pool, HTTP client, cache, message broker. Transient is for the cheap and stateless, or for state that is request-specific — per-request logger children, query builders, command handlers that close over request data.
What you do not need is request-scoped, lazy-singleton, async-singleton, container-managed-singleton-with-init-hook. Those are framework features. They exist for genuinely hard cases: NestJS modules with twenty dependencies that need ordered lifecycle hooks, Java apps where boot order matters, the kind of service where you write @Inject more often than import. If you are not there, you are paying for ceremony you do not use.
The Effect ecosystem's Layer type is worth flagging here as inspiration even if you do not use it: it treats the dependency graph as a value, composes layers like functions, and gets static guarantees that every dependency has a provider. Same idea as the typed Container above, taken further into the type system. Worth reading if you want to see where the pattern can go.
Why the decorator containers feel heavy in 2026
tsyringe, inversify, and typedi all share a base shape: you mark classes with @injectable(), register them, and let the container reflect parameter types at runtime to wire them. (awilix sits in a different family — see the note below.) It works. It is also building on a foundation that has shifted under everyone's feet.
TypeScript 5.0 shipped stage-3 decorators in 2023 with different semantics from the legacy experimentalDecorators mode. Stage-3 decorators do not have parameter decorators yet (the proposal split them off into a separate stage-2 spec), and the metadata they expose is intentionally minimal. The libraries that depend on reflect-metadata and parameter inspection are still pinned to legacy decorators or running on a polyfill.
Three years on, the situation is calmer but not settled. tsyringe works on legacy decorators with reflect-metadata. inversify ships both legacy and stage-3 paths with different APIs depending on which you pick. typedi is classified as Inactive on Snyk Advisor. The real cost is not "which library do I use" — all three are usable. The cost is the configuration debt: experimentalDecorators: true, emitDecoratorMetadata: true, an entry-point import "reflect-metadata", build tools that have to keep that working through every bundler change, and the question that comes up every six months of "should we migrate to stage-3 yet."
For a service with thirty dependencies, that is a lot of plumbing to install for a problem solved in sixty lines of plain TypeScript.
awilix deserves a separate note: it has a non-decorator API that is closer to the typed-container shape above. If you want a third-party container, awilix's createContainer().register({ ... }) pattern is the friendliest one to live with, and it is genuinely useful when you want auto-loading by file convention.
When you actually want a real container
There are three scenarios where the sixty-line container stops being enough and a framework starts paying for itself.
Many request-scoped services. A web server where each request needs its own logger, its own database transaction, its own auth context, its own user-scoped feature-flag client — and you want the framework to enforce that these are not accidentally shared across requests. NestJS does this well. The discipline of declaring a service as @Injectable({ scope: Scope.REQUEST }) and trusting the container to never hand a request-scoped thing to a singleton-scoped thing is a real safety property.
Plugin systems. Code you do not own at compile time has to register services into your container at runtime. A CMS, an IDE, anything with a marketplace of extensions. The container becomes the contract: plugins declare what they provide and what they need, the host wires it. This is what InversifyJS was originally built for.
Long-lived processes with complex lifecycle. A worker that hosts twenty subsystems, each with start, stop, health-check, ordered shutdown, dependency-aware restart on config change. Frameworks have spent years on the corner cases here. Writing it yourself is a project.
If you are in one of those three boxes, pick the framework that fits the box. NestJS for the first, InversifyJS for the second, something like a custom orchestrator (or Effect's Layer + Runtime) for the third. The point is to recognize you are in the box, not to default into it because every TypeScript tutorial uses decorators.
When main.ts wins
If your service has fewer than thirty top-level dependencies, all of them singletons, and a main.ts that fits in a screen and a half, you are done. Wire it by hand. The container is in your head, the file is the manifest, the compiler is the validator.
That covers most TypeScript services shipped today. CLI tools. API servers with a dozen routes. Background workers. Lambda handlers. The vast middle of what people build.
Reach for the typed Container class above when the wall of new actually starts costing you (positional argument bugs, want for lazy or transient, painful test wiring). Reach for a framework when the three-scenario test fires.
The thing to avoid is reaching for the framework first because it feels professional. The container is not the proof you are building serious software. The proof is whether main.ts reads cleanly to someone who has never seen the codebase.
What to write tomorrow
If you are mid-project and want to feel the shift without a rewrite:
- Find your current bootstrap file. The wall of
new, or theapp.module.ts, or the place where things are wired. - Try writing the same wiring as a typed
Containerwith five tokens. Does it shrink? Does it read better? Does the test seam get smaller? - If yes, keep going. If the file is already fine, leave it.
- The next time you start a fresh service, default to the object-literal. Let the wiring grow into the typed container only when there is a specific reason.
The TypeScript ecosystem has spent a decade producing increasingly sophisticated containers. The decade's lesson is that the container is a tool and the wiring is the program. Once you can see the wiring, you can decide what tool fits.
If this matched how you think about TypeScript — small, explicit, type-system doing the work — the entry point of the series is TypeScript Essentials: types, narrowing, modules, async, the daily-driver toolchain. Books 3 and 4 are the bridges if you come from the JVM or modern PHP. Book 5 is for shipping it at work.
- TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: types, narrowing, modules, async, daily-driver tooling
- The TypeScript Type System — From Generics to DSL-Level Types — deep dive: generics, mapped/conditional types, infer, template literals, branded types
- Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: variance, null safety, sealed→unions, coroutines→async/await
- PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP 8+ devs: sync→async, generics, discriminated unions
- TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR
All five books ship in ebook, paperback, and hardcover.

Top comments (0)