A public API is a contract with its consumers, so API developers will (usually) care about breaking it. Here is a simple setup for TypeScript that will help you detect those cases while developing your project, so you know you have to bump your API major version number.

Setup

This setup is built around new npm feature introduced in v6.9.0: package aliases (Yarn supported this for a while now).

b7b54f2d1 362 #3 481 Add support for package aliases. This allows packages to be installed under a different directory than the package name listed in package.json, and adds a new dependency type to allow this to be done for registry dependencies.

So, what we have to do is:

  1. Define an API as a TypeScript interface;
  2. Have our baseline version (first major version) published1, 2;
  3. Use npm starting version v6.9.0 to connect your first published package version as an alias;
  4. Assert you can assign an instance of your current API to a baseline version interface;
  5. Done.

Let’s go through an example project to see how it works:

1. The API

Our example API will be defined by a TypeScript interface with a few healthy methods and types:

interface Fruit {
  readonly cat: "fruit"
}

interface Juice {
  readonly cat: "juice"
}

interface Jam {
  readonly cat: "jam"
}

export interface SomeApi {
  makeJuice(fruit: Fruit): Promise<Juice>

  makeJam(fruit: Fruit): Promise<Jam>
}

Breaking changes should be tested against to your first major version release, ex: 1.0.0, 2.0.0,… Or if we still want to maintain branches for previous major versions (ex. providing bug fixes) we can do that too by connecting them all with an alias testing against each other).

2. Publish

We will “release” our version by adding a version tag with npm version 1.0.0 and then connect it using a git url.

3. Add dependency alias

To install baseline version as your dependency - 1.0.0 in this example - add this line to devDependencies:

{
  "devDependencies": {
    "@lostintime/some-api-base": "git+ssh://[email protected]:lostintime/some-ts-api.git#v1.0.0"
  }
}

Where @lostintime/some-api-base is our package alias. Adding dependency from CLI doesn’t seem to work for git dependencies (yet?):

npm ERR! aliases only work for registry deps

Otherwise, if the package is published on NPM or a private registry use:

4. Add breaking changes test

Now we should test if an instance of our current API version can be assigned to base one on a type level.

import { expect } from "chai"
import { SomeApi } from "../src"
import { SomeApi as SomeApiBase } from "@lostintime/some-api-base"

describe("API", () => {
  describe("Breaking Changes", () => {
    it("didn't happen yet", () => {
      const useApi = (newApi: SomeApi): SomeApiBase => newApi
      // The assertion below does "nothing usefull" here,
      //  just hides "unused useApi constant" error/warning
      // API breaking changes test works at type-level
      expect(useApi).is.a("function") 
    })
  })
})

Then let’s test our tests by introducing a breaking change, ex:

interface Fruit {
  readonly cat: "fruit"
}

interface Juice {
  readonly cat: "juice"
}

interface Jam {
  readonly cat: "jam"
}

export interface SomeApi {
  /**
   * 2nd argument (sugar), added to this function
   */
  makeJuice(fruit: Fruit, sugar: number): Promise<Juice>

  /**
   * 2nd argument (sugar), added to this function
   */
  makeJam(fruit: Fruit, sugar: number): Promise<Jam>
}

Methods now requires second argument: sugar.

$ npm run test

test/breaking.test.ts:8:54 - error TS2322: Type 'import("./some-api/src/index").SomeApi' is not assignable to type 'import("./some-api/node_modules/@lostintime/some-api-base/dist/index").SomeApi'.
  Types of property 'makeJuice' are incompatible.
    Type '(fruit: Fruit, sugare: number) => Promise<Juice>' is not assignable to type '(fruit: Fruit) => Promise<Juice>'.

8       const useApi = (newApi: SomeApi): SomeApiV1 => newApi

So when we get an error starting at that line in our test we know we’re breaking the contract.

Note on variance

While the example above works there is one more thing I’ve also mentioned in my previous post here: for interface methods - bivariant first assignement is still permited, which means that the example below will not trigger a compilation error despite being a breaking change:

interface Fruit {
  readonly cat: "fruit"
}

/**
 * This new type is introduced here
 */
interface Apple extends Fruit {
  readonly type: "apple"
}

interface Juice {
  readonly cat: "juice"
}

interface Jam {
  readonly cat: "jam"
}

export interface SomeApi {
  /**
   * this method now only accepts Apple(s)
   */
  makeJuice(fruit: Apple): Promise<Juice>

  makeJam(fruit: Fruit): Promise<Jam>
}

makeJuice function now expects exactly Apple type, but old code already using your API may be calling it using all kind of Fruit types.

To avoid that we can use function property syntax:

interface Fruit {
  readonly cat: "fruit"
}

interface Juice {
  readonly cat: "juice"
}

interface Jam {
  readonly cat: "jam"
}

export interface SomeApi {
  makeJuice: (fruit: Fruit) => Promise<Juice>

  makeJam: (fruit: Fruit) => Promise<Jam>
}

Then we can break it with:

interface Fruit {
  readonly cat: "fruit"
}

interface Apple extends Fruit {
  readonly type: "apple"
}

interface Juice {
  readonly cat: "juice"
}

interface Jam {
  readonly cat: "jam"
}

export interface SomeApi {
  makeJuice: (fruit: Apple) => Promise<Juice>

  makeJam: (fruit: Fruit) => Promise<Jam>
}

and get the error:

test/breaking.test.ts:8:54 - error TS2322: Type 'import("./some-api/src/index").SomeApi' is not assignable to type 'import("./some-api/node_modules/@lostintime/some-api-base/dist/index").SomeApi'.
  Types of property 'makeJuice' are incompatible.
    Type '(fruit: Apple) => Promise<Juice>' is not assignable to type '(fruit: Fruit) => Promise<Juice>'.
      Types of parameters 'fruit' and 'fruit' are incompatible.
        Property 'type' is missing in type 'Fruit' but required in type 'Apple'.

8       const useApi = (newApi: SomeApi): SomeApiV1 => newApi
                                                       ~~~~~~

  src/index.ts:6:12
    6   readonly type: "apple"
                 ~~~~
    'type' is declared here.

Conclusions

In few simple steps you can get rid of the fear of unexpectedly breaking your API as you’ll know exactly when that may happen and make a decision about it before it gets to production.

Source code for this post can be found here: https://github.com/lostintime/some-ts-api.

1 Before your first version release, usually 1.0, you don’t really care about breaking changes, acording semantic versioning.

2 For this example I had to add TypeScript compilation output to git because using prepare script to build for a package referencing itself seems to get into a loop and never produce any output.