In defense of interface: Using declaration merging to disable "bad parts"

TypeScript's interface has gotten a bit of a bad rap lately, largely because of declaration merging, a behavior of interface that's quite surprising when you first see it. This post explains what declaration merging is, why it is, and how you can use it to iron out some of JavaScript's and TypeScript's wrinkles in your own projects.


I recently implemented a multiselect feature on my product at work. For the most part this involved changing my state from:

interface SelectionState {
featureId: string;
}

to:

interface SelectionState {
featureIds: Set<string>;
}

and tracking down all the resulting errors. But I had a bug! Sometimes I'd click on a feature and it wouldn't get selected. Or, weirder, it would select a random smattering of other, unrelated features.

I eventually realized that I'd converted the existing code from:

setSelectedFeatureId(featureId);

to

setSelectedFeatureIds(new Set(featureId));

In my case featureId was something like "357" and, as you can see:

> new Set("357")
{"3", "5", "7"}

The new Set constructor takes an iterable, and JavaScript strings let you iterate over the sequence of their characters. Instead of selecting feature 357, I was selecting features 3, 5 and 7.

The solution was to change it to:

setSelectedFeatureIds(new Set([featureId]));  // one element array

Fixing one bug is fine, but it's better to find a way to make sure that same bug, or a whole class of bugs, never comes back. Since this is a blog about TypeScript, let's look at how we can use one of TypeScript's most head-scratching features to prevent ourselves (and our coworkers) from ever passing a string to the Set constructor again.

What is Declaration Merging?

One of TypeScript's most surprising behaviors is declaration merging:

interface Product {
name: string;
}

// ...

interface Product {
price: number;
}

declare let furby: Product;
furby.name; // ok, type is string
furby.price; // ok, type is number

Even though they may be separated by thousands of lines of code or in entirely different modules, the two declarations of the Product interface are merged into a single type with both name and price properties.

Declaration merging is surprising and it's given interface a bit of a bad rap. Effective TypeScript even suggests using type instead of interface to avoid it (type aliases are not merged; see Item 13: Know the Differences Between type and interface).

But it's not all bad! Let's look at why TypeScript merges declarations before we use this to ban the evil Set constructor.

Why Declaration Merging?

Declaration merging really shines when you look at the lib setting in tsconfig.json, which models the ECMAScript version that will be available at runtime.

The file lib.es5.core.d.ts contains declarations for built-in methods on the Array type as of 2009 vintage ES5:

interface Array<T> {
length: number;
pop(): T | undefined;
push(...items: T[]): number;
// ...
}

ES2015 added a few new methods, for example Array.prototype.find. When you add es2015 to the lib setting in tsconfig.json, TypeScript pulls in lib.es2015.core.d.ts, which defines those methods, too:

interface Array<T> {
find<S extends T>(predicate: (this: void, value: T, index: number, obj: T[]) => value is S, thisArg?: any): S | undefined;
find(predicate: (value: T, index: number, obj: T[]) => unknown, thisArg?: any): T | undefined;
// ...
}

The net effect when these declarations are merged is that TypeScript will only know about find if your lib includes es2015 (or later). Which is exactly what you want!

I assume that lib was the motivation behind declaration merging. But you can make use of it in your own code, too. Let's use it to ban the "evil" Set constructor.

Banning the Evil Set Constructor

Recall that we want to disallow new Set("string") in our own code without affecting other invocations of the Set constructor.

Here's the declaration of Set from lib.es2015.collections.d.ts:

interface Set<T> {
add(value: T): this;
delete(value: T): boolean;
has(value: T): boolean;
readonly size: number;
// ...
}

interface SetConstructor {
new <T = any>(values?: readonly T[] | null): Set<T>;
readonly prototype: Set<any>;
}
declare var Set: SetConstructor;

Sometimes type declarations model the type of an instance (Set) and the type of the class (SetConstructor) separately. In this case we want to merge something into SetConstructor.

There's also an overload of the constructor in lib.es2015.iterable.d.ts:

interface SetConstructor {
new <T>(iterable?: Iterable<T> | null): Set<T>;
}

We'll want to overload this one. Put this declaration in a .d.ts file somewhere in your project scope, e.g. declarations/ban-evil-set-constructor.d.ts:

interface SetConstructor {
new (str: string): void;
}

When this gets merged with the other SetConstructor declarations, the net effect is that the problematic usage will trigger a type error without affecting the others:

setSelectedFeatureIds(new Set('123'));
// ~~~~~~~~~~~~~~
// Argument of type 'void' is not assignable to parameter
// of type 'ReadonlySet<string>'.
setSelectedFeatureIds(['1', '2', '3']); // ok

What else can you do with this?

That was neat! What else can we do with this technique?

A TypeScript pet peeve of mine has always been that JSON.parse returns a dangerous any type. This declaration comes from lib.es5.d.ts:

interface JSON {
parse(text: string, reviver?: (this: any, key: string, value: any) => any): any;
// ...
}
declare var JSON: JSON;

A safer return type would be unknown. This type was only introduced in TypeScript 3.0 (July 2018) and changing this declaration would be a hugely breaking change for the TypeScript ecosystem as a whole. But there's no reason you can't "fix" it in your own project!

// declarations/safe-json.d.ts
interface JSON {
parse(text: string, reviver?: (this: any, key: string, value: any) => any): unknown;
}

Now you won't be able to use the result of JSON.parse without going through a type assertion or type guard first:

interface ResponseType {
lastModifiedAt: string;
// ...
}

const obj1 = JSON.parse(apiResponse);
obj1.lastModifiedAt;
// ~~~~~~~~~~~~~~~ Object is of type 'unknown'.

const obj2 = JSON.parse(apiResponse) as ResponseType;
obj2.lastModifiedAt; // ok

You can do something similar with Response.prototype.json(), which is used in the fetch API. Its declaration comes from interface Body in lib.dom.d.ts:

interface Body {
readonly body: ReadableStream<Uint8Array> | null;
readonly bodyUsed: boolean;
arrayBuffer(): Promise<ArrayBuffer>;
blob(): Promise<Blob>;
formData(): Promise<FormData>;
json(): Promise<any>;
text(): Promise<string>;
}

If you merge in a new declaration for json(), you can get unknown types back instead of any:

// declarations/safe-json.d.ts
interface Body {
json(): Promise<unknown>;
}

Conclusions

Declaration merging is surprising and controversial, but it's not all bad. TypeScript uses it to great effect in modeling which library methods will be available at runtime. And you can use it modify or disallow those methods as you like for your project.

A few notes to conclude:

  • As with all type-level constructs, this only affects type checking. The runtime behavior the Set constructor is not affected, either in your own code or in library code.

  • This technique is best used either to make the built-in types stricter, or to disallow certain things. If you add declarations that don't reflect reality at runtime, you can create a really confusing situation. Incorrect types can be worse than no types.

  • Making a constructor return void isn't itself an error. So calling new Set("string") on its own will not cause a type error. You only get the error when you try to use the resulting value, which gets a void type. This is fine in our case, but if the method you want to "knock out" already returns void, then this technique won't work as well. (link to "user-defined type error" issue)

References:

Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. The book's 62 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »