Dependency Injection in TypeScript (Frontend)
Published Lesson 0001 15 minutes

What Is a Dependency?

Identify a dependency and move the transport choice into the caller.

dependencies coupling constructor injection
Status Published
Published
Updated
Difficulty Beginner

Objectives

  • Identify a dependency in a small frontend service.
  • Explain tight coupling with a concrete TypeScript example.
  • Write constructor injection and move the transport choice to the caller.
  • Explain why an injected fake can test service logic without network access.

Prerequisites

  • You can read and write basic TypeScript classes, functions, and promises.
  • You understand the role of browser APIs such as fetch.

Objectives

By the end of this lesson, you should be able to:

  • Spot a dependency in a frontend service.
  • Explain why a service that reaches for fetch is tightly coupled to the network.
  • Move the transport choice from the service to its caller with constructor injection.
  • Replace a real transport with a small fake when you test the service.

Prerequisites

You need basic TypeScript knowledge. You should be comfortable with classes, functions, promises, and the browser fetch API.

1. Spot the dependency

Frontend code uses many things that a module did not create and cannot control: fetch, localStorage, the system clock, and API clients. Consider this small price loader:

class PriceService {
  async loadPrice(productId: string): Promise<number> {
    const response = await fetch(`/api/price/${productId}`);
    const data = await response.json();
    return data.priceUsd as number;
  }
}

Ask one question. What does this class need that it does not own?

The answer is the network. fetch is a dependency: something the module needs to do its job but should not decide for itself.

The class works, but two choices are now welded together:

  • Every use of PriceService performs real HTTP.
  • A test of loadPrice needs the network or must monkey-patch globalThis.fetch.

Confusing use with ownership

A class can use a dependency without owning the decision about which implementation to use. If changing the transport means editing the service, the service is coupled to a concrete detail.

Coupling is how far a change travels. Here, changing how prices are fetched means editing the class itself. The service and transport are tightly coupled because they share concrete details, not only a job description. See the coupling glossary entry.

2. Move the dependency into the constructor

Dependency injection needs no framework or container. The class declares what it needs, and the caller supplies it:

class PriceService {
  // Needs a function: id -> price. That is all.
  constructor(private readonly fetchPrice: (id: string) => Promise<number>) {}

  async loadPrice(productId: string): Promise<number> {
    return this.fetchPrice(productId);
  }
}

The function parameter is constructor injection. The choice of transport moved out of the class and to the code that builds it.

The caller can now wire the real transport at the application edge:

const service = new PriceService(async (id) => {
  const response = await fetch(`/api/price/${id}`);
  return (await response.json()).priceUsd as number;
});

The service changed in one place. Before, it fetched its dependency. After, it received that dependency from its caller.

Give, do not take. That is the essential idea of dependency injection. Fowler describes the pattern and its history.

No interface was introduced, and none was needed. Interfaces and abstractions are a separate design move covered by the next planned lesson.

3. See the difference

The service below is unchanged between the two examples. Only the constructor argument changes. One transport uses the network; the other is an offline fake.

Explanatory network-dependent example

This transport can fail when the page has no /api/price/:id endpoint. It is shown to explain the coupling; it is not an offline demo and does not run in this lesson:

const realTransport = (id: string): Promise<number> =>
  fetch(`/api/price/${id}`)
    .then((response) => {
      if (!response.ok) {
        throw new Error(`transport failed (HTTP ${response.status})`);
      }
      return response.json();
    })
    .then((data) => data.priceUsd as number);

const networkService = new PriceService(realTransport);

Offline fake

This fake returns deterministic values and never calls the network:

const fakeTransport = async (id: string): Promise<number> =>
  ({ "p-042": 1290, "p-007": 499 })[id] ?? 0;

const offlineService = new PriceService(fakeTransport);
const price = await offlineService.loadPrice("p-042");
// 1290
Offline demonstration

Run the service with an injected transport

The class stays the same. Choose a transport to see how the wiring changes its behaviour.

Explanatory network-dependent example. The real transport is shown above but never runs here. Simulate its failure to see the same coupling without a network request.

$ waiting. Choose a transport.

The class source stayed the same. The behaviour changed at the wiring. That is testability as a result of the design, not of a mocking tool.

4. Retrieve the idea

Try to answer these questions before opening the model answers:

  1. In the terms used in this lesson, what counts as a dependency of a module?
  2. What exactly does constructor injection change in the after code?
  3. What lets one class produce two different behaviours?
  4. Which symptom most directly shows that the before class is tightly coupled?
Show model answers
  1. A dependency is something the module needs but does not own or control, such as fetch, storage, or the clock.
  2. The dependency arrives as a constructor parameter, so the class signature tells the truth about what it needs.
  3. The caller swaps the constructor argument while the class source remains unchanged.
  4. Tests need the real network, so the service cannot be exercised without its concrete transport.
"Dependency injection is..." Finish the sentence

It is a wiring technique where objects receive dependencies from outside, usually through the constructor, instead of creating or fetching them. A framework is optional.

Why does injection improve testability?

The class no longer owns the transport. A test can construct it with a fake, without network access or monkey-patching, so the test exercises the service logic.

Exercises

  1. Find a browser API used directly inside one of your services. Write down what the service needs and what choice it currently owns.
  2. Replace that direct call with a function constructor parameter. Keep the parameter as small as the service actually needs.
  3. Create a deterministic fake and use it to exercise the service without a network request.
  4. Explain which code is now the composition point: the service or the caller that constructs it?

Further reading

Citations

Optional progress

Lesson completion

Mark this lesson complete. The setting stays in this browser. You do not need an account, and the site does not send it to a server.

Not marked complete. This setting stays in this browser.

AI assistance: This lesson was manually adapted and reviewed for the public Astro site with AI assistance.

Help improve this lesson

Found an error or have an improvement to suggest? Share it in the public GitHub issue tracker.

Report an error or suggest an improvement