Description
We recently upgraded Google to use TypeScript 3.5. Here is some feedback on the upgrade.
(For background, recall that Google is a monorepo of billions of lines of code. We use a single version of TypeScript and a single set of compiler flags across all teams and upgrade these simultaneously for everyone.)
We know and expect every TypeScript upgrade to involve some work. For example, improvements to the standard library are expected and welcomed by us, even though they may mean removing similar but incompatible definitions from our own code base. However, TypeScript 3.5 was a lot more work for us than other recent TypeScript upgrades.
There were three main changes in 3.5 that made it especially painful. (The other changes were also required work, but these three are worth some extra discussion.) We believe most of these changes were intentional and intended to improve type checking, but we also believe the TypeScript team understands that type checking is always just a tradeoff between safety and ergonomics.
It is our hope that this report about TS 3.5 as applied to a large codebase will help the TypeScript team better evaluate future situations that are similar, and we make some recommendations.
Implicit default for generics
This was the headline breaking change in 3.5. We agree with the end goal of this change, and understand that it will shake up user code.
Historically when TypeScript has introduced type system changes like this, they were behind a flag.
Suggestion: Using a flag here would have allowed us to adapt to this change separately from the other breaking changes in 3.5.
The main way this failed is in circumstances where code had a generic that was irrelevant to what the code did. For example, consider some code that has a Promise resolve, but doesn't care about what value the Promise to resolves to:
function dontCarePromise() {
return new Promise((resolve) => {
resolve();
});
}
Because the generic is unbound, under 3.4 this was Promise<{}>
and under 3.5 this becomes Promise<unknown>
. If a user of this function wrote down the type of that promise anywhere, e.g.:
const myPromise: Promise<{}> = dontCarePromise();
it now became a type error for no user benefit.
The bulk of churn from this generics change was in code like this, where someone wrote a {}
mostly because it was what the compiler said without really caring what type it was.
One common concrete example of this don't-care pattern are the typings for the d3
library, which has a very complex d3.Selection<>
that takes four generic arguments. In the vast majority of use cases the last two are irrelevant, but any time someone saves a Selection
into a member variable, they ended up writing down whatever type TS inferred at that time, e.g.:
mySel: d3.Selection<HTMLElement, {}, null, undefined>;
The 3.5 generics change means that {}
became unknown
simultaneously in almost every interaction with d3.
Suggestion: Our main conclusion about specifically d3 is that the d3 typings are not great and need some attention. There are some other type-level issues with them (outside of this upgrade) that I'd like to go into more, but it's not relevant to this upgrade.
Another troublesome pattern are what we call "return-only generics", which is any pattern where a generic function only uses it in the return type. I think the TypeScript team already knows how problematic these are, with lots of inference surprises. For example, in the presence of a return only generic, the code:
expectsString(myFunction());
can be legal while the innocent-looking refactor
const x = myFunction();
expectsString(x);
can then fail.
Suggestion: We'd be interested in seeing whether TypeScript could compile-fail on this pattern entirely, rather than picking a top type ({}
or unknown
). Users are happy to specify the generic type at the call site, e.g. myFunction<string>()
but right now the compiler doesn't help them see when they need it. For example, maybe the declaration myFunction<T>(...)
could always require a specific T
to be inferred, because you can always write myFunction<T=unknown>()
for the case where you are ok with a default.
One other common cause of return-only generics is a dependency injection pattern. Consider some test framework that provides some sort of injector function:
function getService<T>(service: Ctor<T>): T;
where Ctor<T>
is some type that matches class values. The intended use of this is e.g.
class MyService { … }
const myService = getService(MyService);
This works great up until MyService
is generic, at which point this again picks an arbitrary <T>
for the return type. The problem here is that we pass the MyService
value to getService
, but then we get back the MyService
type, which needs a generic.
One last source of return-only generics that we discovered is that the generic doesn't need to be in the return type. See the next section.
filter(Boolean)
TypeScript 3.5 changed the type of the Boolean
function, which coerces a value to boolean
, from (effectively)
function Boolean(value?: any): boolean;
to
function Boolean<T>(value?: T): boolean;
These look like they might behave very similarly. But imagine a function that takes a predicate and returns an array filter, and using it with the above:
function filter<T>(predicate: (t: T) => boolean): (ts: T[]) => T[];
const myFilter = filter(Boolean);
With the 3.4 definition of Boolean, T
is pinned to any
and myFilter
becomes a function from any[]
to any[]
. With the 3.5 definition, T
remains generic.
We believe this change was intentional, to improve scenarios like this.
The RxJS library uses a more complex variant of the above pattern, and a common use of it creates a function composition pipeline with a filter(Boolean)
much like the above. With TS 3.4, users were accidentally getting any
downstream of that point. With TS 3.5, they instead get a generic T
that then feeds into a larger inference. You can read the full RxJS bug for some more context.
One of the big surprises here is that everyone using RxJS was getting an unexpected any
at this point. We knew to look for any
in return types, but now we know that even if you accept an any
in an argument type, via inference this can cause other types to become any
.
Suggestion: A more sophisticated definition of Boolean
, one that removed null|undefined from its generic, might have helped, but from our experiments in this area there are further surprises (search for "backwards" on the above RxJS bug). This was also not mentioned in the list of breaking changes in 3.5. It's possible its impact was underestimated because it disproportionately affects RxJS (and Angular) users.
Set
In TypeScript 3.4,
const s = new Set();
gave you back a Set<any>
. (It's actually a kind of amusing type, because in some sense almost everything still works as expected -- you can put stuff in the set, .has()
will tell you whether something is in the set, and so on. I suspect this might be why nobody noticed.)
TypeScript 3.5 made a change in lib.es2015.iterable.d.ts
that had the effect of removing the any
, and the generic change described above made it now infer unknown
.
This change ended up being tedious to fix, because the eventual type errors sometimes were pretty far from the actual problem. For example, in this code:
class C {
gather() {
let s = new Set();
s.add('hello');
return s;
}
use(s: string[]) { … }
demo() {
this.use(Array.from(this.gather()));
}
}
You get a type error down by the Array.from
but the required fix is at the new Set()
. (I might suggest the underlying problem in this code is relying on inference too much, but the threshold for "too much" is difficult to communicate to users.)
Suggestion: we are surprised nobody noticed this, since it broke our code everywhere. The only thing worth calling out here is that it seems like nobody made this change intentionally -- it's not in the breaking changes page, and the bug I filed about it seems to mostly have prompted confusion. The actual change that I think changed what overloads got picked looks harmless. Perhaps the main lesson we learned here is that we needed to discover this earlier and provide this feedback earlier.
PS: It also appears new Map()
may have the same problem with any
.
Conclusion
I'd like to emphasize we are very happy with TypeScript in general. It is our hope that the above critical feedback is useful to you in your design process for future development of TypeScript.