
Dependency Injection (DI) is a pattern that enhances the maintainability, scalability, and testability of your code. In TypeScript, we can leverage higher-order functions to implement Dependency Injection seamlessly, promoting a more modular and flexible codebase.
While it may sound like a complex pattern, many tools out there make it look very complicated. However, the reality is that DI is simply passing parameters, mostly to the constructor. As Rúnar Bjarnason casually once said, dependency injection is "really just a pretentious way to say 'taking an argument.'"
To provide a short and easy example, let's imagine we are working on a cab (taxi) Uber-like application. We've received a request to implement a rides pricing feature. For the sake of this article, the only rule is that the price of a ride must be $2 per kilometer for a given route.
Example
- The distance from my house address to the airport is 20 KM for the available itinerary.
- The price returned by the application should be 20 * 2 = $40.
We'll follow the domain model:
typeAddress={street:string;city:string;}typeRoute={origin:Address;destination:Address;};typeItinerary={route:Route;distance:number;}typePricedRide={itinerary:Itinerary;price:number;}
However, the implementation of the ride pricing feature needs to know the itinerary to calculate the price based on the distance. The route itinerary can be fetched using a third-party service like Google Maps, Google Addresses, or others. Let's assume we haven't made a decision on which service to use.
Implementation using class-based Dependency Injection:
Since the itinerary 3rd-party service provider is not known yet, we can abstract it behind an interface and inject it into the class that calculates the price as a constructor parameter, like the following:
interfaceItineraryService{getItinerary(route:Route):Promise<Itinerary>}classRideService{constructor(privateitineraryService:ItineraryService){}asyncpriceRide(route:Route):Promise<PricedRide>{constitinerary=awaitthis.itineraryService.getItinerary(route)constprice=itinerary.distance*20;return{itinerary,price}}}
Let's break down the code above
ItineraryService
is an interface that exposes thegetItinerary
method signature. The implementation details of that interface can be done once a decision is made on what itinerary service provider to use, or even use a fake implementation.RideService
class has a constructor that takes an ItineraryService implementation instance. It doesn't matter what the concrete implementation to use is since it respects the interface contract.RideService
exposes thepriceRide
method that takes a Route object and returns a promise of aPricedRide
object after getting an itinerary for the passed route.
I kept the code short by removing some details like error handling.
Towards function-based DI
Now let's try to get rid of that verbose class-based version and replace it with another version that uses just functions. Since DI is basically passing parameters and functions are first-class citizens in JavaScript and TypeScript, we can just use a function to calculate the price and pass the itinerary fetching dependency as a function as well. The class-based code can be refactored to:
constpriceRide=async(route:Route,getItinerary:Function):Promise<PricedRide>=>{constitinerary=awaitgetItinerary(route)constprice=itinerary.distance*20return{itinerary,price}}
The code is now shorter and simpler, isn't it?
We have a small issue in the function implementation above:getItinerary
function type accepts any function. Let's fix this by adding the following type to our domain model:
typeGetItinerary=(route:Route)=>Promise<Itinerary>
Dependencies First, Data Last
If we want to mimic the class-based approach, we can justFLIP the order of thepriceRide
function arguments like the following:
constpriceRide=async(getItinerary:GetItinerary,route:Route)=>{constitinerary=awaitgetItinerary(route)constprice=itinerary.distance*20return{itinerary,price}}
Flipping the arguments order as above is more of a functional style, where the input data are always set as the last function argument. This will allow us to partially applypriceRide
.
Partial application
It is a functional programming concept that can be applied to create a new function by just passing some arguments to a function. For example, we can uselodash.partial
to get a new function from ourpriceRide
like the following:
constpartialPriceRide=_.partial(priceRide,getItinerary)// create a function with a dependencyasyncpartialPriceRide(route)// -> ride price
WheregetItinerary
and route are concrete implementations.
This is exactly the steps to make to use the class-based version:
constrideService=newRideService(itineraryService)// create an object with a dependencyasyncrideService.priceRide(route)// -> ride price
Great! Now, since partial application is a function that returns a function, instead of usinglodash
to create ourpriceRide
function, we can define the function like the following:
constpriceRide=(getItinerary:GetItinerary):PriceRide=>async(route:Route):Promise<PricedOrder>=>{constitinerary=awaitgetItinerary(route)constprice=itinerary.distance*20return{itinerary,price}}
We need to addPriceRide
function definition to the domain model:
typePriceRide=(route:Route)=>Promise<PricedRide>
- The function now returns a function instead of a promise of
PricedRide
by taking all dependencies as parameters. - The returned function returns a promise of
PricedRide
and uses the dependency to fetch an itinerary. - Since
priceRide
creates a closure, all dependencies are accessible for the returned functions but private for the outside world (like in the class-based version).
Runtime configuration checking
We can take advantage of thepriceRide
function and add a runtime check that verifies that the passedGetItinerary
parameter is really a function:
constpriceRide=(getItinerary:GetItinerary):PriceRide=>{if(typeofgetItinerary!=="function")thrownewTypeError("Get Itinerary is not a function")returnasync(route:Route):Promise<PricedRide>=>{constitinerary=awaitgetItinerary(route)constprice=itinerary.distance*20return{itinerary,price}}}
The major downside of this approach is that the function creates a closure for every call, which means a new object is created every time we call the function, however this can avoided by calling the function once at the composition root level which will be a topic for another article.
Top comments(3)
For further actions, you may consider blocking this person and/orreporting abuse