interface has gotten a bit of a bad rap lately, largely because of declaration merging, a behavior of
I recently implemented a multiselect feature on my product at work. For the most part this involved changing my state from:
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:
In my case
featureId was something like
"357" and, as you can see:
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:
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.
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
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
But it's not all bad! Let's look at why TypeScript merges declarations before we use this to ban the evil
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.
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
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"
Recall that we want to disallow
new Set("string") in our own code without affecting other invocations of the
Here's the declaration of
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
There's also an overload of the constructor in
We'll want to overload this one. Put this declaration in a
.d.ts file somewhere in your project scope, e.g.
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:
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
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
If you merge in a new declaration for
json(), you can get
unknown types back instead of
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
Setconstructor 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
voidisn'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
voidtype. 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)