@ncjamieson

TypeScript: Prefer Interfaces

October 26, 2020 • 4 minute read

Stripes
Photo by Markus Spiske on Unsplash

Last week, I noticed a Twitter thread from Rob Palmer in which he described some performance problems that were caused by the use of type alias declarations in TypeScript.

Specifically, the use of a type alias declaration effected a much larger .d.ts output:

Yesterday I shrank a TypeScript declaration from 700KB to 7KB by changing one line of code 🔥

In the thread, Rob points out that the reason this happens is because type alias declarations can be inlined, whereas interfaces are always referenced by name.

Let’s have a look at some code that demonstrates this inlining behaviour.

Here’s a TypeScript playground snippet in which a type alias is used to declare the callback signature:

type ReadCallback = (content: string) => string;
function read(path: string, callback: ReadCallback) {}

The effected .d.ts output — which is shown in the playground’s right-hand-side panel — looks like this:

declare type ReadCallback = (content: string) => string;
declare function read(path: string, callback: ReadCallback): void;

The ReadCallback type alias is included in the .d.ts output and the read function’s signature refers to it by name.

However, the output will be different if we scope the type alias declaration — by shoving it into an IIFE — like this:

const read = (() => {
  type ReadCallback = (content: string) => string;
  return function (path: string, callback: ReadCallback) {};
})();

The scoped type alias declaration won’t be included in the output and callback’s signature will be inlined:

declare const read: (
  path: string,
  callback: (content: string) => string
) => void;

IIFEs aren’t the only scenario in which inlining can occur. They’re used here so that inlining can be demonstrated in the TypeScript playground.

Inlining is something that won’t happen with interfaces, as interfaces are always referred to by name. If an interface is scoped in a similar manner, like this:

const read = (() => {
  interface ReadCallback {
    (content: string): string;
  }
  return function (path: string, callback: ReadCallback) {};
})();

compilation will fail with an error:

Exported variable 'read' has or is using private name 'ReadCallback'.

Interfaces aren’t inlined. They are referred to by name and if the name is private, compilation will fail.

In early versions of TypeScript, the behavioural differences between type aliases and interfaces were significant. However, in recent versions there is less of a distinction and that has led to some developers preferring type aliases over interfaces.

The performance problems mentioned in Rob’s thread suggest that perhaps interfaces should be preferred. And Daniel Rosenwasser’s endorsement of interfaces is emphatic:

Honestly, my take is that it should really just be interfaces for anything that they can model. There is no benefit to type aliases when there are so many issues around display/perf.

We tried for a long time to paper over the distinction because of people’s personal choices, but ultimately unless we actually simplify the types internally (could happen) they’re not really the same, and interfaces behave better.

For developers interested in ensuring that interfaces are used wherever possible, I’ve added a prefer-interface ESLint rule to eslint-plugin-etc. It will effect a lint failure whenever it finds a type alias declaration that could be declared as an interface. The rule has a fixer — and a suggestion — and can replace type alias declarations automatically.


Rob has written about the types/interfaces issue — and a whole lot more — in his blog post: 10 Insights from Adopting TypeScript at Scale

And, in the TypeScript documentation, Daniel gives some reasons for preferring interfaces over intersections.


Nicholas Jamieson’s personal blog.
Mostly articles about RxJS, TypeScript and React.
TwitterGitHubSponsor

© 2020 Nicholas Jamieson All Rights ReservedRSS