DEV Community

Yawar Amin
Yawar Amin

Posted on • Updated on

Interfaces for scaling and testing JavaScript

I'VE recently been working with Flow, the JavaScript typechecker, and come to confirm my suspicions that interfaces are the 'killer app' of both Flow and TypeScript. They function in a remarkably similar style in both typecheckers–you'd almost think that their designers collaborated to come up with their functionality. And most importantly because of that functionality, they preserve a lot of JavasScript's 'dynamic' look-and-feel while backing it up with solid type safety.

Here's an example interface (in Flow, but as I said, TypeScript is remarkably similar):

export interface WeatherService {
  getForecast(
    city: string,
    numDays: number,
    tempUnit?: TempUnit,
  ): Promise<Forecast[]>,
}

/** Celsius or Fahrenheit */
export type TempUnit = 'C' | 'F'

export interface Forecast {
  city: string,
  day: {year: number, month: number, day: number},
  tempUnit: TempUnit,
  highTemp: number,
  lowTemp: number,
}
Enter fullscreen mode Exit fullscreen mode

A quick recap

Interfaces in Flow and TypeScript describe the shapes of JavaScript objects. Remember that in JavaScript, a lot of different things are objects–starting from record-style object literals like {a: 1, b: true} to class instances like new Date(), to arrays like [1, 2], and even functions like x => x + 1. Interfaces can describe them all, from standard attributes like Forecast#city to methods like WeatherService#getForecast, and more.

What's more, interfaces can describe objects after the fact, that is, after the objects/classes/etc. have already been defined. This makes them a lot less clunky than the traditional interfaces you may be used to from object-oriented languages where classes need to explicitly implement interfaces (which they can in TS/Flow too, but don't need to).

Interface conformance

Even better: interfaces only demand that conforming objects contain at least the listed members. That is, they don't care if they contain more members. For example, objects of the following class will conform to WeatherService:

/** `baseUri` must not end with a slash */
export default function liveWeatherService(
  baseUri: string,
): WeatherService {
  return new LiveWeatherService(baseUri)
}

class LiveWeatherService {
  baseUri: string

  constructor(baseUri: string) {
    this.baseUri = baseUri
  }

  async getForecast(
    city: string,
    numDays: number,
    tempUnit?: TempUnit = 'C',
  ): Promise<Forecast[]> {
    const response = await fetch(
      `${this.baseUri}/forecast?city=${city}&numDays=${numDays}&tempUnit=${tempUnit}`,
    )
    const json = await response.json()

    // Assume a function to decode raw JSON to `Forecast` type
    return decodeForecasts(json)
  }
}
Enter fullscreen mode Exit fullscreen mode

The exported function pulls off a neat little trick–it creates a new LiveWeatherService instance and upcasts it to WeatherService, which succeeds because the object conforms to the interface (even without explicitly declaring it so). This also enforces that the LiveWeatherService's baseUri instance member is hidden (i.e. private), despite Flow not actually supporting class private members. (This works the same way in TypeScript, but typically people use private class members there.)

In fact, the LiveWeatherService class itself is hidden–users have no way to access and instantiate it directly. We provide a simple function to instantiate a WeatherService object to decouple interface and implementation.

Abstract data types

The WeatherService interface hides everything about the underlying object except that we can do one thing with it, that is call its getForecast method. This is really the essence of abstract data types–types that are defined solely in terms of what you can do with them, not exposing their internal shape.

Flow/TS pull off a neat twist on that though, in a way that plays to JavaScript's strengths: they let interfaces contain members that are simple JavaScript object properties, meaning that they also let you expose exactly their internal shape. This is invaluable in a language like JavaScript, where objects can be created and passed around in such a lightweight way, and come in handy for many different uses, from writing query DSLs to passing in optional parameters to functions.

Coming back to abstract data types (ADTs) for a second–they are, of course, one of the big breakthroughs in our understanding of how to program at large scale. Using types, modules, or classes that hide implementation details from each other, we're able to achieve that sought-after goal of programming: decoupling, i.e. cutting dependencies.

Testability

One of the places where this is most apparent is when testing your code. Automated tests are the first consumer of every well-designed codebase, and especially unit tests serve as a canary of how modular the code is. If it's not modular enough that its units can be tested in isolation, chances are it's not flexible and robust in the face of inevitable change.

The key to unit tests is the 'unit' part–that is, testing one thing, in isolation. And as I mentioned, interfaces allow you to cut dependencies and thus isolate pieces of code from each other.

For example, suppose you want to test some piece of code that uses the above WeatherService. This code may be simple or complex. Let's assume for now it's some logic to return a message depending on today's high temperature:

export async function weatherMessage(
  weatherService: WeatherService,
): Promise<string> {
  const [{highTemp}] = await weatherService.getForecast(
    /* city = */ 'Toronto',
    /* numDays = */ 1,
  )

  if (highTemp < 0) return 'Brr!'
  else if (highTemp < 20) return 'Chilly!'
  else if (highTemp < 30) return 'Noice!'
  else return 'Hot!'
}
Enter fullscreen mode Exit fullscreen mode

Despite triggering a network call in the production code, this function is easy to test. It's isolated from the WeatherService implementation, and doesn't need to know about how the service was constructed with a base URI and is making a network call. All it cares about is that it gets something that conforms to the service interface.

Mocks

You guessed where this was heading: mocks. But instead of the questionable practice of mocking (as a verb), here you can use the actual best practice of mocks (as a noun). As José Valim recommends, we can swap out the real service implementation for a mock that conforms to the same interface. For example:

type Responses = {|
  getForecast?: Forecast[][],
|}

export default function mockWeatherService(
  responses: Responses,
): WeatherService {
  return new MockWeatherService(responses)
}

class MockWeatherService {
  responses: Responses

  constructor(responses: Responses) {
    this.responses = responses
  }

  async getForecast(
    city: string,
    numDays: number,
    tempUnit?: TempUnit,
  ): Promise<Forecast[]> {
    return this.responses.getForecast ?
      this.responses.getForecast.shift() :
      unhandled({city, numDays, tempUnit})
  }
}

function unhandled<A>(args: Object): A {
  throw new Error(`Unhandled request with arguments: ${args}`)
}
Enter fullscreen mode Exit fullscreen mode

Now, a unit test can look like this:

describe('weatherMessage', () =>
  it('works for sub-zero', () =>
    expect(weatherMessage(mockWeatherService({
      getForecast: [[{
        city: '',
        day: {year: 0, month: 0, day: 0},
        tempUnit: 'C',
        highTemp: -1,
        /* This obviously doesn't make sense but it doesn't matter for
           this test */
        lowTemp: 0,
      }]]
    }))).toMatchSnapshot()
  )
)
Enter fullscreen mode Exit fullscreen mode

The snapshot should be: Brr!.

A few different things are happening here:

  • We set up a mock weather service that is constructed with mock responses (with the type Responses. Interestingly, this is an exact object type, not an interface, because we want a compile error if we pass in a wrong response name.)
  • It responds with the responses, popping them out of the response array one by one, as weatherMessage triggers service calls
  • If there are no responses for a service call, the mock service throws an 'unhandled request' exception, so that the test fails

This mock enables us to start writing tests quickly. We can start with a minimal, failing test:

expect(weatherMessage(mockWeatherService({})))
  .toMatchSnapshot()
Enter fullscreen mode Exit fullscreen mode

... and get an 'unhandled request' exception that tells us which response mock we need to provide next. If you have service mocks set up like this, writing unit tests for e.g. controllers in an Express app would involve a series of test exceptions that would guide you through the process of setting up mock responses. The responses you provide would determine which branch of controller logic was taken and thus which service calls (if any) would be triggered next. The test writing process, as you can imagine, would be almost interactive.

Interfaces–the good parts

Of course, interfaces are not a magic bullet. You'll notice that even in the above examples I didn't use them for every type definition. Used judiciously, they pack quite a lot of punch in the ways I described. But they have their limitations.

I think we're fortunate in the JavaScript world that we have type systems that let us model JavaScript in a (relatively) safe way. One of the modelling tools they got absolutely right is interfaces. Remember that Flow/TS types are purely compile-time constructs, they are erased from the output JavaScript. With such low syntactic and runtime cost, interfaces and other types let us manage complexity and achieve quality while still shipping features for our users.

Oldest comments (0)