Dependency Injection without classes

cherif_b

Cherif Bouchelaghem

Posted on January 6, 2024

Dependency Injection without classes

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:

type Address = {
  street: string;
  city: string;
}

type Route = {
  origin: Address;
  destination: Address;
};

type Itinerary = {
  route: Route;
  distance: number;
}

type PricedRide = {
  itinerary: Itinerary;
  price: number;
}
Enter fullscreen mode Exit fullscreen mode

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:

interface ItineraryService {
  getItinerary(route: Route): Promise<Itinerary>
}

class RideService {
  constructor(private itineraryService: ItineraryService) {}

  async priceRide(route: Route): Promise<PricedRide> {
    const itinerary = await this.itineraryService.getItinerary(route)
    const price  = itinerary.distance * 20;
    return {
      itinerary,
      price
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's break down the code above

  • ItineraryService is an interface that exposes the getItinerary 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 the priceRide method that takes a Route object and returns a promise of a PricedRide 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:

const priceRide = async (route: Route, getItinerary: Function): Promise<PricedRide> => {
  const itinerary = await getItinerary(route)
  const price  = itinerary.distance * 20
  return {
    itinerary,
    price
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

type GetItinerary = (route: Route) => Promise<Itinerary>
Enter fullscreen mode Exit fullscreen mode

Dependencies First, Data Last

If we want to mimic the class-based approach, we can just FLIP the order of the priceRide function arguments like the following:

const priceRide = async (getItinerary: GetItinerary, route: Route) => {
  const itinerary = await getItinerary(route)
  const price  = itinerary.distance * 20
  return {
    itinerary,
    price
  }
}
Enter fullscreen mode Exit fullscreen mode

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 apply priceRide.

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 use lodash.partial to get a new function from our priceRide like the following:

const partialPriceRide = _.partial(priceRide, getItinerary) // create a function with a dependency
async partialPriceRide(route) // -> ride price
Enter fullscreen mode Exit fullscreen mode

Where getItinerary and route are concrete implementations.

This is exactly the steps to make to use the class-based version:

const rideService = new RideService(itineraryService) // create an object with a dependency
async rideService.priceRide(route) // -> ride price
Enter fullscreen mode Exit fullscreen mode

Great! Now, since partial application is a function that returns a function, instead of using lodash to create our priceRide function, we can define the function like the following:

const priceRide = (getItinerary: GetItinerary): PriceRide => async (route: Route): Promise<PricedOrder> => {
  const itinerary = await getItinerary(route)
  const price  = itinerary.distance * 20
  return {
    itinerary,
    price
  }
}
Enter fullscreen mode Exit fullscreen mode

We need to add PriceRide function definition to the domain model:

type PriceRide = (route: Route) => Promise<PricedRide>
Enter fullscreen mode Exit fullscreen mode
  • 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 the priceRide function and add a runtime check that verifies that the passed GetItinerary parameter is really a function:

const priceRide = (getItinerary: GetItinerary): PriceRide => {
  if (typeof getItinerary !== "function") throw new TypeError("Get Itinerary is not a function")
  return async (route: Route): Promise<PricedRide> => {
    const itinerary = await getItinerary(route)
    const price  = itinerary.distance * 20
    return {
      itinerary,
      price
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
cherif_b
Cherif Bouchelaghem

Posted on January 6, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related