JavaScript dependency injections with TypeScript

I like advocating for TypeScript through concrete examples where it's a significantly better developer experience than vanilla JavaScript.

One pattern that can be greatly improved by adding TypeScript is dependency injection.

Some simple, "starter" code:

import api from './api';

class Store {
  getData(url) {
    return api(url);
  }
}

// And elsewhere you could do
const store = new Store();
store.getData();

But then in tests, we get:

it('getData', () => {
  const store = new Store();
  store.getData(); // NO! fetch not defined, etc. ๐Ÿ˜ก

  // ๐Ÿ”จ bring out nock
  nock('url')
    .get(...)
})

๐Ÿ’ก Wait, let's use dependency injection to make this easier for unit testing:

class Store {
  constructor(api) {
    this.api = api;
  }

  getData(url) {
    return this.api(url);
  }
}

// And elsewhere you could do
import api from './api';
const store = new Store(api);

// And store tests are very easy:
it('getData', () => {
  const apiMock = Sinon.stub.resolve(dataMock);

  const store = new Store(apiMock);
  store.getData(); // ๐Ÿ™‚
})

Our tests look good! However, by adding this abstraction, we've made the mental model of our code more complex. Now, whenever someone that hasn't read the code recently/ever comes to Store, they see this.api(). What does this code do? cmd+click just takes you to the constructor (maybe). You have to go find the exact new Store() instance you care about and go find the api from there.

Does TypeScript magically make this better? No. This is a tradeoff we accept for adding abstraction to our code. And if done right, it is much better than having to stub every single fetch call in the store tests, for example.

However, it would be nice to know something about this.api right away. This is where TypeScript can help!

class Store {
  constructor(api: (url: string | number) => Promise<boolean>) {
    this.api = api;
  }
}

We know right away that api takes a string, but also a number, and it resolves to a boolean, without having to look at any other code.

Additionally, mocking can become too powerful without types:

it('getData', () => {
  const apiMock = () => 'hi';

  const store = new Store(apiMock);
  const data = store.getData(); // NO!
})

This test passes but it doesn't represent reality, getData() returns a promise. This could be caused by an incorrect mock, or more likely, by the code changing and the test continuing to pass even though it didn't get updated.

If we were writing our tests in TypeScript, we could prevent this API drift since new Store() would only accept mocks that implemented the correct interface.

โ€ƒยทโ€ƒโ€ƒยทโ€ƒโ€ƒยทโ€ƒ
Dilraj Singh profileDilraj S.Dilraj Singh

I code and do some other things, like practice writing.

๐Ÿ‘จ๐Ÿฝโ€๐Ÿ’ปโ›ฐโšฝ๏ธ

GithubยทLinkedInยท@dilraj_singh