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:
|
to:
|
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:
|
to
|
In my case featureId
was something like "357"
and, as you can see:
|
The new Set
constructor takes an iterable, and JavaScript string
s 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:
|
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:
|
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:
|
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:
|
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
:
|
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
:
|
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
:
|
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:
|
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
:
|
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!
|
Now you won't be able to use the result of JSON.parse
without going through a type assertion or type guard first:
|
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
:
|
If you merge in a new declaration for json()
, you can get unknown
types back instead of any
:
|
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 callingnew 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 avoid
type. This is fine in our case, but if the method you want to "knock out" already returnsvoid
, then this technique won't work as well. (link to "user-defined type error" issue)
References:
- TypeScript Handbook page on declaration merging.
- TypeScript Handbook page on global augmentation.