Introduction
Some parameters have nothing to do with what a function is actually for. They are the dependencies every function in a call chain has to carry: a database connection, a logger, a tracing context, a currency-formatting locale. Passing them explicitly at every call site is correct and repetitive, so most languages reach for a dependency-injection framework. Scala answers it in the language.
Python: manual wiring, every time
Python has DI frameworks, but a large share of real codebases pass dependencies through constructors by hand, all the way down. It is simple and it works, and it means every intermediate function that never touches a dependency still has to accept it and forward it.
def process_payment(order, logger, clock):
return charge(order, logger, clock) # charge() doesn't use logger
# directly, but has to accept
# and re-forward it anyway,
# because something further
# down eventually needs it.
def charge(order, logger, clock):
validate(order, clock) # same story: clock threaded through
# a function that only needs it to hand
# off to yet another function.TypeScript: needs a library to feel automatic
TypeScript has no built-in DI mechanism. Constructor injection is a manual pattern, and getting the "resolve and inject this for me" behaviour Spring or Scala give you means pulling in InversifyJS or tsyringe, configured with decorators and a runtime container. It is Spring in miniature, imported as a dependency rather than provided by the language.
@injectable()
class PaymentService {
constructor(
@inject(TYPES.Logger) private logger: Logger,
@inject(TYPES.Clock) private clock: Clock,
) {}
}
// Requires a runtime container (InversifyJS here) configured
// separately, resolving dependencies via decorators reflected at
// runtime: the type system itself doesn't verify the wiring.Java: a real framework, runtime wiring, reflection
Spring is the default answer in Java and it works well. It is also a large, separate piece of infrastructure that wires dependencies together through reflection and annotations at runtime. If a required bean is missing, you find out when the application starts, or, worse, when that specific code path runs in production. Not at compile time.
@Service
class PaymentService {
private final Logger logger;
private final Clock clock;
@Autowired // wiring resolved via reflection, at container startup
PaymentService(Logger logger, Clock clock) {
this.logger = logger;
this.clock = clock;
}
}
// Forget to register a Clock bean anywhere in the Spring context, and
// this fails at application startup with a reflective stack trace:
// the Java compiler itself has no idea Clock was ever required.Scala: compile-time, no container, no reflection
A given instance is a value the compiler supplies to any parameter declared with using, resolved at compile time by searching for a matching given in scope. No runtime container and no reflection. If no matching instance exists, the compiler fails the build. There is no startup failure and no runtime exception to wait for.
trait Logger:
def log(msg: String): Unit
given Logger with
def log(msg: String): Unit = println(s"[LOG] $msg")
def processPayment(order: Order)(using logger: Logger): Receipt =
logger.log(s"processing ${order.id}")
charge(order)
def charge(order: Order)(using logger: Logger): Receipt =
logger.log("charging")
Receipt(order.id)
// Call site doesn't pass Logger explicitly: the compiler finds the
// 'given Logger' above and threads it through both calls automatically:
processPayment(Order("id-123"))
// prints
// [LOG] processing id-123
// [LOG] charging
// Delete the 'given Logger' line entirely, and this becomes a
// COMPILE ERROR: "no given instance of type Logger was found",
// caught before the code ever runs, not at container startup.Note what charge didn't need: it declares using logger: Logger because it uses logger directly, and the compiler resolves it the same way at every call site. No forwarding boilerplate, no annotations, no separate configuration file mapping interfaces to implementations.
Swapping implementations is just scoping a different given
Testing against a different implementation, a no-op logger say, needs no mocking framework and no test-specific container config. It is a different given in scope for that test:
given Logger with
def log(msg: String): Unit = () // no-op, for tests
// Anything called within this scope now resolves THIS Logger instead:
// resolution rules are the same compile-time search, just with a
// different given visible.Compile-time resolution catches an agent's missing wiring immediately
When an AI agent adds a new service that needs a dependency injected, say a new PaymentService that needs a MetricsClient, the Spring/InversifyJS version of that mistake (forgetting to register the new bean/provider) fails at application startup or, worse, only when that specific code path executes in production. The Scala version fails to compile, with an error that names the exact missing type, the moment the agent tries to build.
That is a much tighter loop for a human and an agent alike. Instead of finding the wiring gap through a runtime stack trace three layers away from the missing dependency, the compiler names it before the code runs.
