This page is also available in Italiano.

Continua in italiano

Why no reflect-metadata

Published on July 24, 2026

The very first constraint in the TinyDI spec was not a feature — it was a prohibition: no reflect-metadata, no decorators, no automatic constructor injection, no class scanning. Everything most popular TypeScript DI containers (TSyringe, InversifyJS) are built around was explicitly off the table. This post is about why, and what that trade-off actually looks like once the container is built.

What reflection-based DI buys you

TSyringe and InversifyJS let you write a class, sprinkle it with @injectable()/@inject() decorators, and have the container inspect the constructor's parameter types at runtime (via reflect-metadata and TypeScript's emitDecoratorMetadata) to build the whole dependency graph for you. You never write new UserService(repo, logger) yourself — the container discovers what a class needs and supplies it.

That is genuinely convenient for large graphs of classes. It is also not free: it requires a specific compiler flag most modern TypeScript setups (especially anything targeting isolatedModules or a non-tsc transpiler like esbuild/SWC) do not enable by default, a runtime-only metadata polyfill, and a way of wiring dependencies that is invisible unless you already know the decorator is there.

The decision: pass the container explicitly

TinyDI's factories receive the container as an explicit argument instead of capturing it from a closure or having it injected via reflection:

ts
export type Factory<T> = (container: Container) => T;
 
container.registerFactory(
  UserServiceToken,
  (c) => new UserService(c.resolve(DatabaseToken)),
);

Every dependency a service needs shows up as a c.resolve(...) call, right there in the factory body. There is no step where the container silently figures out what to pass — you always see it. This is the entire idea behind TinyDI's tagline, explicit over magic.

Consequences and trade-offs

The trade-off is real, not just rhetorical, and it shows up in two concrete places we hit while building the rest of the project.

First: circular dependency detection. Because every resolution goes through the same explicit resolve() call, tracking which token is currently being built is just an array push/pop around a function call — no need to reconstruct an implicit dependency graph from decorator metadata.

Second: framework integration. Each of the seven examples — including Vue, Nuxt and React — bridges TinyDI into that framework's own DI-like mechanism (provide/inject, a Nuxt plugin, React Context) with a few lines of hand-written adapter code. A reflection-based container would need framework-specific knowledge of how each of those systems constructs objects; ours doesn't need to know anything about them at all. Neither of these was the point of the decision — they were a consequence of it, discovered after the fact rather than designed for in advance.

What you give up

You do write more code: every constructor argument is spelled out at the registration site instead of inferred from parameter types. For a handful of services this is a non-issue; for a very large graph of classes, it is the actual cost of this design. TinyDI's bet is that explicitness pays for itself in debuggability and framework independence — see Comparison for the fuller TSyringe/InversifyJS trade-off table.

to navigate to select Esc to close