— Mooooom, can I have some "Effect-like-DI"?
— NO! We have "Effect-like-DI" at home.
— At home: Reader.
After reading this post, you'll get the joke.
I often see people praising DI in Effect.
So I want to share a little DIY, good old-fashioned, backyard implementation. It takes a different, simpler approach, but still tracks dependencies at the type level.
First things first: every computation should track its required dependencies at the type level — in Effect, that's the third type parameter, R.
interface Computation<out Value, in Services extends {} = {}> {
(services: Services): Value
}
So, let's say a computation is a function that expects some services as input to calculate a Value. We'll pass them as a map:
{ serviceName: serviceImplementation }
Let's start by implementing something like Effect.sync — a computation that doesn't require any Services.
const sync = <Value,>(cb: () => Value): Computation<Value> =>
() => cb()
Now let's build a runner for these computations:
const run = <A, R extends {}>(
reader: {} extends R ? Reader<A, R> : never
) => reader({} as R);
{} extends R is the condition that ensures we can only run computations that don't require any dependencies.
Basically, we can run functions that don't need anything by simply calling them. If they do require services, we'll have to provide those as arguments — or just {} if nothing is required.
Alright, let's make a Hello World:
const helloDI: Computation<void> = sync(() => console.log("Hello, DI!"))
const result = run(helloDI) // => void
Now let's extract our logger into a service and update the computation:
interface Logger {
log: (...args: ReadonlyArray<unknown>) => void
}
const ConsoleLogger: Logger = globalThis.console
const helloDI: Computation<void, { logger: Logger }> = (services) =>
services.logger.log("Hello, DI!")
run(helloDI) // Error
We get an error because the computation requires us to provide a logger service.
Let's write a function that provides dependencies!
We should keep in mind that Services is essentially a Key-Value map. We'll pass dependencies as a subset of this map using Partial<Services>, while omitting the dependencies we've already provided from the original map using Omit<Services, keyof Provided>.
const provideServices =
<Value, Services extends {}>(computation: Reader<Value, Services>) =>
<Provided extends Partial<Services>>(
provided: Provided
): Reader<Value, Omit<Services, keyof Provided>> =>
(services) => {
const value = computation({ ...req, ...provided } as {} as Services);
return value;
};
Let's provide the dependency:
const runnable = provideServices(helloDI)({ logger: ConsoleLogger })
run(runnable) // OK
Now let's also log some random numbers!
interface Random {
random: () => number;
}
const MathRandom: RandomService = globalThis.Math
const getRandom: Computation<void, { random: Random }> = (services) =>
services.random()
const logNumber = (num: number): Computation<void, { logger: Logger }> =>
(services) => services.logger.log(`Random: ${num}`)
Now we need to combine these two computations into a new one.
The idea is simple: first, we'll get a random number, then we'll log it. But to do that, we'll need both services — random and logger.
const andThen =
<Serivces1 extends {}, Value1>(
computation1: Computation<Value1, Serivces1>
) =>
<Value2, Services2 extends {}>(
fn: (a: Value1) => Computation<Value2, Services2>,
): Computation<Value2, Services1 & Services2> => {
return (r: Services1 & Services2) => {
const value1 = computation(r); // We'll take only Services1 from the combined services
const computation2 = fn(value1);
const value2 = computation2(r); // And here we'll take only Services2
return value2;
};
};
So, here's what we get:
const program: Computation<void, { logger: Logger, random: Random }> =
andThen(getRandom)(logNumber)
const runnable = provideServices(program)({
logger: ConsoleLogger,
random: MathRandom
})
run(runnable) // OK
You might notice that we explicitly declared the required services in the types beforehand.
But we can make this a bit more interesting — something like yield* MyServiceTag in Effect.
What if we could simply access a service and have it automatically added to the computation's dependencies?
So, what we need is a function that takes some service as input and immediately returns it back as the computation's result, so we can use it later.
const tag = <Service extends {}>(): Reader<Service, Service> => {
return (service) => service;
};
Now let's put everything together:
const RandomTag = tag<{ random: Random }>();
const LoggerTag = tag<{ logger: ConsoleLogger }>();
const getRandom = flatMap(RandomTag)(
(services) => sync(() => services.random.random() ** 2),
);
const logNumber = (num: number) =>
flatMap(LoggerTag)((services) => sync(() => services.logger.log(num)));
const program = flatMap(getRnd)(logRnd);
const withConsole = provide(program)({
logger: ConsoleLogger,
});
const runnable = provide(withConsole)({
random: MathRandom,
});
run(program); // error
run(withConsole); // error
run(runnable); // ok
Actually, this is an approach to organizing DI used in libraries like fp-ts. In Haskell, this Computation<> type is also called Reader.
So, this idea is nothing new.
Under the hood, Effect's Context is essentially the same map, but dependencies are tracked at the type level using a union (|) rather than an intersection (&). Earlier versions used &, but the approach was abandoned due to TypeScript limitations.
So, if you're looking for Effect-like DI, check out the Reader family of modules in fp-ts. https://gcanti.github.io/fp-ts/modules/Reader.ts.html
Playground: https://tsplay.dev/NBOazm
Now I've challenged myself to write an equivalent of Effect.gen, so we could simply yield* computations and get more readable code.
Here's the result I was looking for:
const program = gen(function* () {
const randomService = yield* RandomTag;
const randomNumber = random.random();
const logger = yield* LoggerTag;
logger.log(randomNumber);
return randomNumber;
});
We'll need two things to implement this.
1. Add a symbol to Computation so we can use yield* on it.
In the implementation, we'll simply return yield this — the call to .next() will return the Computation<V, S> itself.
interface Computation<out Value, in Services extends {} = {}> {
(services: Services): Value;
[Symbol.iterator](): Generator<Computation<Value, Services>, Value, any>;
}
Let's add a constructor that takes a callback and wrap all functions that return a Computation with it — sync, andThen, tag, etc.
const make = <Value, Services extends {} = {}>(
cb: (services: Services) => Value
): Computation<Value, Services> => {
const cb_ = cb as Computation<Value, Services>;
cb_[Symbol.iterator] = function* () {
return yield computation;
};
return cb_;
};
2. And now, gen itself.
Let's add some type helpers. Notice that Services are accumulated using &.
I recently learned an interesting TypeScript trick: if you want to cast something at the type level, you can simply use Extract<Some, CastTarget>. That's exactly what I used for Computation.Services:
declare namespace Computation {
export type Any = Computation<any, any>;
export type Value<C extends Any> = ReturnType<C>;
export type Services<C extends Any> =
Extract<UnionToIntersection<Parameters<C>[0]>, {}>;
}
const gen = <C extends Computation.Any, A>(
f: () => Generator<C, A, unknown>
): Computation<A, Computation.Services<C>> => {
return make((services: Computation.Services<C>) => {
const iterator = f();
let state = iterator.next();
while (!state.done) {
const computation = state.value;
const value = computation(services);
state = iterator.next(value);
}
return state.value;
});
};
And that's it! Now we can write code in an imperative style.
I've also slightly reworked tag so that it returns the service itself instead of a piece of the map.
Playground: https://tsplay.dev/mxrP1w
Top comments (0)