Skip to main content

TS Ep 65: Context & Dependency Injection

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 65: This Article
Global variables are the enemy of unit testing. Standard Dependency Injection requires complex external libraries. Effect-TS solves this natively using the R (Requirement) parameter in its type signature.

TL;DR (Quick Summary)
#

  • The Problem: Relying on globally imported singletons (like import db from "./db") makes mocking for unit tests nearly impossible.
  • The Solution: Effect tracks dependencies in the R channel of Effect<A, E, R>.
  • Context.Tag: Acts as a unique identifier (a token) for a specific service or interface.
  • Yielding the Service: You access the service using Effect.flatMap(ServiceTag, service => ...) without caring how the service is implemented.
  • Providing the Service: The compiler will literally refuse to run your program until you inject the actual implementation at the edge using Effect.provideService.

1. Introduction: The Singleton Disease
#

In standard Node.js and TypeScript architectures, developers typically connect to a database in one file, export it as a singleton, and import it everywhere.

// 🔴 Bad: The Global Singleton
import { database } from "./databaseSingleton";

export async function getUser(id: string) {
  // This function is permanently hardcoded to the production database!
  return await database.query(`SELECT * FROM users WHERE id = ${id}`);
}

If you want to write a unit test for getUser, you have to use a tool like jest.mock("./databaseSingleton") to intercept the import. This is brittle, magical, and constantly breaks during refactoring.

Object-Oriented Programming solves this via Constructor Injection (passing the database into a class constructor). But passing dependencies down through 10 layers of function calls (Prop Drilling) is exhausting.

Effect-TS provides a native, functional Dependency Injection (DI) system.


2. Step-by-Step: Building an Injected Service
#

Let’s rebuild the getUser function using Effect’s Context system.

Step 1: Define the Interface
#

First, we define exactly what our Database Service looks like.

// 🟢 Step 1: The abstract interface
interface DatabaseService {
  readonly queryUser: (id: string) => Effect.Effect<User, Error>;
}

Step 2: Create the Context Tag
#

We need a unique identifier so the Effect runtime can look up this service later. We create a Context.Tag.

import { Context } from "effect";

// 🟢 Step 2: The unique token
// This Tag acts as the "Key" to find the DatabaseService in the DI container.
export const DatabaseTag = Context.GenericTag<DatabaseService>("DatabaseService");

Step 3: Use the Tag in Business Logic
#

Now, inside our deep business logic, we do not import a global database. We ask the Effect runtime to give us the service associated with DatabaseTag.

import { Effect } from "effect";

// 🟢 Step 3: Access the service
export const fetchAndLogUser = (id: string) => Effect.pipe(
  // We ask the Runtime to yield the DatabaseService
  DatabaseTag,

  // Once yielded, we use it!
  Effect.flatMap(db => db.queryUser(id)),

  Effect.flatMap(user => Effect.sync(() => console.log(user)))
);

If you hover over fetchAndLogUser, you will see this signature:

Effect.Effect<void, Error, DatabaseService>

The compiler has automatically tracked that this pipeline requires a DatabaseService to run!


3. Providing the Service at the Edge
#

If you try to execute fetchAndLogUser right now, the compiler will violently reject it.

// 🔴 TS2345: Argument of type 'Effect<void, Error, DatabaseService>' is not assignable...
Effect.runPromise(fetchAndLogUser("123"));

To run the program, you must Provide the service implementation. You do this at the absolute edge of your application (e.g., your Express server entry point, or inside your test file).

The Production Implementation
#

// Create the live production implementation
const liveDatabase: DatabaseService = {
  queryUser: (id) => Effect.tryPromise({
    try: () => productionDb.query(`...`),
    catch: () => new Error("DB Failed")
  })
};

// 🟢 Inject the production service!
const executableProgram = Effect.provideService(
  fetchAndLogUser("123"),
  DatabaseTag,
  liveDatabase
);

// Runs perfectly. The 'R' requirement is now 'never'.
Effect.runPromise(executableProgram);

The Test Implementation
#

In your unit tests, you simply provide a mock implementation! No Jest mocks required!

test("fetchAndLogUser works", async () => {
  // Create a fake test implementation
  const mockDatabase: DatabaseService = {
    queryUser: (id) => Effect.succeed({ id, name: "Test User" })
  };

  // 🟢 Inject the mock service!
  const testProgram = Effect.provideService(
    fetchAndLogUser("123"),
    DatabaseTag,
    mockDatabase
  );

  await Effect.runPromise(testProgram);
  // Asserts pass beautifully!
});

4. Comparison: OOP Injection vs Effect Context
#

FeatureOOP (NestJS / Inversify)Effect-TS Context
BoilerplateHigh. Requires @Injectable() decorators and heavy classes.Minimal. Just a Context.Tag.
Type SafetyRelies on Reflect-metadata (runtime).100% Compile-time mathematically proven.
Missing DependencyCrashes at runtime during server boot.Refuses to compile. Red squiggly lines instantly.
ScopingDifficult to scope dependencies per request.Trivial. Services can be provided deeply or globally.

5. Troubleshooting & Common Errors
#

Error 1: Forgetting to Provide a Service
#

TS2345: Argument of type 'Effect<void, Error, DatabaseService | LoggerService>' is not assignable to parameter of type 'Effect<void, Error, never>'.

The Cause: You piped a function that requires a LoggerService, but you only provided the DatabaseService. The Fix: You must provide all services before running. You can chain .provideService() calls, or use Layers (which we will cover in the next episode) to provide massive webs of dependencies at once.

Error 2: Tag Naming Collisions
#

The Mistake: You created two Context.GenericTags in different files but gave them the same string identifier: Context.GenericTag("MyService"). If you merge them into the same runtime, one might overwrite the other. The Fix: Always ensure the string passed to GenericTag is unique, usually matching the interface name exactly.


Summary & Next Steps
#

In this episode:

  • We identified how global variables destroy testability.
  • We used Context.GenericTag to create unique identifiers for our interfaces.
  • We observed how the R parameter automatically tracks required services deep within our pipeline.
  • We used Effect.provideService to inject production implementations and mock implementations safely.

Providing single services is easy. But what if UserService depends on DatabaseService, which depends on ConfigService? How do we wire up a massive, enterprise application without writing 50 .provideService() lines?

In Episode 66: Layers & Service Wiring, we will introduce Layer, the ultimate dependency graph resolver in Effect-TS!

typescript - This article is part of a series.
Part 65: This Article