Ever tense up when a new TypeScript version drops? You cross your fingers and hope it plays nice with your IDE. You’re not alone. Relax into this fireside chat with WebStorm lead developer and Team Lead, Andrey Starovoyt, as we pull back the curtain on TypeScript’s integration in WebStorm.
To set the stage, can you help me distinguish between the TypeScript Language Server and the TypeScript compiler?
The TypeScript compiler transpiles TypeScript files to plain JavaScript and enumerates the corresponding errors. Meanwhile, the TypeScript Language Service is a layer over the compiler that provides a way to communicate with it and get extended information about the project. This means you end up with all the errors for a single file, code completion for an exact position in the code, and lots of other useful things. The TypeScript Language Service is close to the LSP specification, only with several minor tweaks. You can think of it as an ancestor of LSP. Indeed, many ideas from the TypeScript LS were taken into consideration when LSP was designed.
WebStorm TypeScript Service vs. TypeScript Language Service, what are the essential differences?
WebStorm has a long history of supporting TypeScript, which all started with the File Watchers plugin. The first version of our TypeScript integration just ran the TypeScript compiler under the hood for the current file. In most cases, it would just compile the file and provide any errors. It was okay at the time because we had similar functionality for other languages like CoffeeScript. This was around 2013.
At some point, we recognized that TypeScript was going to be something big, and we needed to ensure better integration with it. It was at this moment that I started working on TypeScript support for WebStorm. To start with, I just implemented a kind of host that communicated with the TypeScript compiler directly and sent the information to WebStorm. That improved performance dramatically. We ignored the compilation result but did use it to provide error highlighting and some other useful stuff.
Then the TypeScript team recognized that they needed some kind of TypeScript support inside their IDEs (they were working on VS Code at the time). Eventually, they introduced the TypeScript Language Service, which is like an API that any editor can use to integrate with the TypeScript compiler and get information from it.
The TypeScript Language Service API was highly restrictive and had a small API surface, as the TypeScript team had only really just started working on it. In the end, we decided to extend the API on the service side with our own commands to supplement the limited number of native commands you were able to use to communicate with the language service. We worked out that we could extend the TypeScript Language Service’s functionality and provide several more commands that we can use in the IDE, such as compiling and other similar actions.
Once again, the only reason we took these steps was because the TypeScript service was very young and didn’t have the functionality we wanted. Later, TypeScript released several updates to the language service, which meant we had to rewrite it several times because the API changed so dramatically between, for instance, TypeScript versions 1.8 and 2.0. As a consequence, we created different branches in our code base, so that “if it’s TypeScript v1.8, we’ll use this implementation, and if it’s v2.0, we’ll use this one”. We continued to improve our integration over time. So, on our side, we added several more layers over the TypeScript Language Service to provide our own functionalities like compiling and other things that we had to implement to support the unique features of WebStorm.
How was integrating TypeScript 5 different from previous versions?
Good question! I think I first need to explain some of the specificities of integrating a new version of TypeScript to answer it properly. Before we start the integration process, there are three major considerations. First, we’ve got to support the new TypeScript version as part of our WebStorm TypeScript model. In WebStorm, we have our own TypeScript model and we have to reflect all the changes outside of the compiler on our side to ensure we are able to provide refactoring, new inspections, code completions, and all the other features our users enjoy and have come to rely on. So, every time a new TypeScript version is released, we have to check all the semantic changes made and update our model accordingly.
The second key concern is the JavaScript standard library. For WebStorm, we eventually decided that it didn’t make any sense to have our own implementation of standard libraries like DOM APIs. For that purpose, we use TypeScript declaration files. And since we have a ton of internal tests to perform, it usually takes some time, because we sometimes have to update the data or fine-tune our models to understand the typings and new changes better.
The third big consideration relates to updating our integration with the TypeScript Language Service. Usually, we can bundle the new version of the service and it just works – except when it doesn’t. Indeed, we have been caught out by breaking changes before. In most cases, however, it’s just a matter of updating the binary and everything works. Finally – to get back to your original question – we have TypeScript 5. It was quite tricky because there were a lot of changes made to the internal API. But the biggest problem for us was that the TypeScript team migrated the whole module system from namespaces to ECMAScript modules (https://devblogs.microsoft.com/typescript/typescripts-migration-to-modules/), and almost all our workarounds to overwrite behaviors wouldn’t work anymore. For modules, we cannot just take a variable and say that the implementation of this variable is different because the modules are read-only. This ended up breaking multiple parts of our integration.
I recognized our old approach wouldn’t work anymore and we’d have to basically rewrite everything from scratch using the evolved TypeScript Language Service API. This is kind of a big deal because a lot of the internal functionality we provide inside of our TypeScript integration is based on the TypeScript Language Service. Small wins included basic things like code highlighting and error highlighting – all that stuff worked just fine. We didn’t rewrite the behavior coloring in the IDE, so it worked the same way for TypeScript 0.8 as for TypeScript 5.0: We just send a “Get Errors” command to the language service, and the service will then return something.
Therefore, the basic functionality actually worked, but all our smart features like compiling inside of the IDE, supporting custom configurations, loading plugins for different frameworks – that is to say, everything outside of the basics – just broke!
One thing I haven’t yet mentioned is that migrating to TypeScript 5 broke everything related to our framework support. For example, we have a Vue plugin for the TypeScript compiler, which extends some basic functionality to provide proper support inside Vue files. Well, this integration was broken completely because we used the same approach we always had. So, it was at this point that we decided not to rewrite everything but rather to use another approach and introduce Volar support. Instead of restoring our own implementation, we used the official implementation provided by the Vue team – and it worked pretty well.
What features did you add on top of the ones provided out of the box by the TypeScript Language Service?
That’s another good question. As it happens, a great deal of what we implemented is now present in TypeScript 5. So, for many features, I didn’t have to implement them from scratch – I just had to change our functionality in the IDE so that it uses the proper TypeScript Language Service command. For example, we had our own custom command to call up the list of files inside of the project. Now, we use public TypeScript commands, which do exactly the same thing. There were several similar cases, but unfortunately it didn’t work for all of our functionalities. Compiling is probably the most pertinent example of one such failure: The TypeScript Service simply is not designed for compiling. And the second major feature that we had to implement from scratch is custom config names for frameworks. For example, for Angular, we support tsconfig.app.json. Custom configurations like this are not supported by the TypeScript Language Service. In order to support this, therefore, we also had to implement an extension to the TypeScript Language Server.
As I say, the old architecture consisted of our own layer on top of the TypeScript Language Service, so we loaded the TypeScript Language Server and made our own wrapper to provide different commands. Starting from TypeScript 5.0, we no longer rely on the wrapper, but rather we use the TypeScript Language Server directly. So now, instead of building a wrapper, we just use a compiler plugin, which adds several new commands that we can use on the WebStorm side. Unfortunately, this functionality wasn’t available in the older version. This new approach is a lot clearer and better separated.
How is backward compatibility handled? Because not everyone will always be on the latest version of TypeScript.
We don’t use tsserver
as is. We made our very own small wrapper, which loads the TypeScript Language Server. We need the wrapper to decide which version of our integration we wish to initiate because, as you said, we need to support all versions of TypeScript.
What did you consider particularly hard or time-consuming when integrating a new version of TypeScript, other than breaking changes?
Rewriting everything from scratch multiple times is extremely time-consuming! Because, like I said, we had the compiler functionality inside our wrapper. Nowadays, though, most of the functionality comes from the TypeScript Language Server – it has almost all the necessary methods, and we can just reuse them as we wish. For example, at one point, we had our own cache over the compiler, just to reduce the time. Then, at a later stage, the TypeScript team added a cache as well, which they then implemented on the TypeScript Language Server side. This meant we didn’t need our cache implementation anymore and could instead just stick to the default implementation. There are several similar things that we had to rewrite on our end, so we could use the default implementation and not our own.
Are there any further improvements planned for the TypeScript integration?
The current plan is to improve everything around our integrations with the service to provide more features and more functionality in different contexts. We’ve already made some improvements for Svelte, for example, and we are planning to do something similar for Astro.
The team is probably the most excited about the changes in the integration with the TypeScript Server that are currently being worked on, which improves the correctness of inferred types but also can have a positive impact on the overall performance. For more details, you can find the announcement post here. I believe it will bring some really cool functionality inside the IDE, thanks to TypeScript 5 basically breaking our entire integration!