Typescript language service plugins are great for intercepting typescript language servers. There aren't many resources available to back them up, unfortunately.
In the Part 1 of the series, I will outline some of the features of typescript plugins using a small example plugin. We will learn what they can do and how to enable them in a ts project.
In Part 2, lets learn how to embed typescript service plugins using a VS code extension (useful if you are extension developer) React CSS Modules, an extension I built to improve CSS modules experience in VS Code
What are they ?
Have you ever wondered about the 'plugins' field in tsconfig's compiler options?
# tsconfig.json
{
"compilerOptions":{
"plugins":[
{
"name":"awesome-ts-plugin"
}
]
}
}
They are nothing but additional programs that are run inside your editor.
They act as a messenger between typescript server and your editor. They are also capable of sending their own messages.
TypeScript Language Service Plugins ("plugins") are for changing the editing experience only
When do you need them ?
Lets use an example to understand why and when do we need them.
So you are working on a React
project that uses styled-components
. styled-components
is one of the most popular CSS in JS framework.
Unlike CSS modules or CSS files in general, styled-components
enables you to write CSS inside typescript modules (with all CSS syntax sugars).
Lets imagine you have a Button
component that exposes a html 'button' element such as the following.
# Button.ts
export const Button = styled.button`
padding: 1rem;
border-radius: 0.5rem;
`;
Do you think that you will get CSS intellisense in your code editor for the above?
Yeah you guessed it right. Sadly You won't!
That is because TS language server doesn't speak CSS. So your editor cannot provide any CSS related intellisense.
That's when TS language service plugins come into the picture. Luckily for styled-components
users, there is already a great plugin added by styled-components maintainers which you can install in VS code or add it as a plugin to your tsconfig
This plugin acts as middleman between the language server and your editor. Whenever it encounters the syntax 'styled.<element>', it takes control over the intellisense providers (completions, definitions etc of your editor environment that is running the language server) and provide the necessary intellisense for CSS language
Usage
- As a plugin included in tsconfig.json
plugin
option
"compilerOptions":{
"plugins":[
{
"name":"some-ts-plugin"
// ...other options of the plugin goes here
}
]
// rest of your compiler options
}
As a plugin inside VS Code's extension context (only for vs code extension developers)
- In the above scenario of
styled-components
, thetypescript-styled-plugin
comes built in with the vs code extensionvscode-styled-components
- The extension under its hood uses the
typescript-styled-plugin
- In the above scenario of
In this post, lets create a small typescript plugin that intercepts 'Go to Defintion' action in VS code. For the purpose of this example, lets remove the defintion results of useState
hook in React
Projects
Initial Setup
For this example I'm gonna use pnpm
as package manager. Also the source is available on github for your reference.
Open your favourite termnial and create a directory called ts-remove-usestate
which is what we will call our plugin.
Let gets started by initialising a new pnpm
project
pnpm init
the above command would create the following entry in your package.json
{
"name": "ts-remove-usestate",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
Lets leave it as is for now and come back to it later.
Open the directory in your favourite code editor. Mine is VS code!
Dependencies
For this project we require only typescript
to be our dependency so lets go ahead install it
pnpm add typescript
NOTE: It shouldn't matter if you don't install the latest version but for the purpose of this example lets make sure that you install typescript@4.9.X
Tsconfig
So far so good!. We have typescript installed as a dependency.
Lets go ahead and create our tsconfig.json
with the following contents
{
"paths": ["src/**"],
"exclude": ["examples"],
"compilerOptions": {
"target": "es2017" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"module": "commonjs" /* Specify what module code is generated. */,
"sourceMap": true /* Create source map files for emitted JavaScript files. */,
"outDir": "./out" /* Specify an output folder for all emitted files. */,
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
"strict": true /* Enable all strict type-checking options. */,
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}
All our source will be inside src
directory and compiled output is going to be in out
directory as specified by outDir
in compilerOptions
.
We will create examples folder later when enabling our plugin in react project.
Init Function
Lets create a src
directory and new index.ts
file
When your plugin is listed in tsconfig.json
, the typescript-language-server
identifies the plugin using the name and it will look for a init function exported from the module.
So lets start by creating our init
function in index.ts
function init({
typescript: ts,
}: {
typescript: typeof import("typescript/lib/tsserverlibrary");
}) {
// Plugin logic will go here
}
export = init;
Export syntax supported by typescript is used here to allow CommonJS and AMD workflow (more on that here)
The init function receives an object with the typescript
property attached to it. It will be the representative of the ts server namespace which we will exploit soon.
Creating Proxy Language service
Typescript language server expects the init
function to return an object with a property create
.
create
is a function which is defined as follows
function init({
typescript: ts,
}: {
typescript: typeof import("typescript/lib/tsserverlibrary");
}) {
// create function
function create(info: ts.server.PluginCreateInfo) {
// This function will create our proxy interceptor
}
return { create };
}
The create function should create a proxy
, which is a decorator that wraps the main ts language server instance.
This proxy
should be returned by the create
function. So our create
function is going to look like
/// rest of the code above
function create(info: ts.server.PluginCreateInfo) {
const proxy: ts.LanguageService = Object.create(null);
for (let k of Object.keys(info.languageService) as Array<
keyof ts.LanguageService
>) {
const x = info.languageService[k]!;
// @ts-expect-error - JS runtime trickery which is tricky to type tersely
proxy[k] = (...args: Array<{}>) => x.apply(info.languageService, args);
}
return proxy;
}
/// rest of the code below
Decorator pattern through Object.create is used here to avoid any effects on the main language server instance. Any behavioural change we might add to the proxy instance will not affect the main language server instance.
Putting it all together
function init({
typescript: ts,
}: {
typescript: typeof import("typescript/lib/tsserverlibrary");
}) {
function create(info: ts.server.PluginCreateInfo) {
const proxy: ts.LanguageService = Object.create(null);
for (let k of Object.keys(info.languageService) as Array<
keyof ts.LanguageService
>) {
const x = info.languageService[k]!;
// @ts-expect-error - JS runtime trickery which is tricky to type tersely
proxy[k] = (...args: Array<{}>) => x.apply(info.languageService, args);
}
}
return { create };
}
export = init;
Testing
In order to test the plugin lets add a simple log message that says 'Hello from remove use state!'. We can accomplish this by doing
function create(info: ts.server.PluginCreateInfo) {
/// Proxy setup is above
info.project.projectService.logger.info("Hello from remove use state!");
return proxy;
}
Lets compile everything together using ts compiler
tsc -p tsconfig.json --watch
If there a no ts errors , the compilation should be successfull
We should now have the compiled source in out/index.js
. So this will be our main entry file for the outside world. Lets go ahead and add this to the main
field of package.json
{
"main": "out/index.js"
}
Lets also add a compile script
{
"scripts": {
"compile:watch": "tsc --watch"
}
}
Enabling
To enable the plugin, lets quickly create a react project with vitejs. Lets make a directory called examples
.
Lets create a react project called test-ts-plugin
inside examples
cd examples
yarn create-vite
The create-vite
CLI will guide you through the steps. Call the project as test-ts-plugin
Choose React and Typescript as the stack.
Run the below commands and make sure you have all the dependencies installed.
cd test-ts-plugin
yarn
Open this folder in VS code.
Now lets enable our ts-remove-usestate
plugin in the newly created project.
Lets first add the plugin to the test-ts-plugin/tsconfig.json
{
"compilerOptions": {
/// rest of the compiler options
"plugins": [
{
"name": "ts-remove-usestate"
}
]
}
}
Open App.tsx
inside your VS code.
Since the plugin is activated by typescript-language-server
running inside VS code extension context, we can search for our plugin in ts server logs and see if the activation was successfull.
Open VS code command pallete and run the following command
> Typescript: Open TS Server log
The above command should open the TS server log in a new tab.
Search for our plugin name ts-remove-usestate
inside the logs. You should see this message
Info 27 [17:39:35.837] Failed to load module 'ts-remove-usestate'
in your server log.
Now what did happen?. You asked TS language server to activate/load our plugin but it couldn't find it in your node_modules
.
So we need to add the plugin as a dependency. Lets add it as a dev dependency.
Now if the plugin is available in npm , we might as well do
yarn add -D ts-remove-usestate
Since our plugin is not available in npm but only in our filesystem lets ask yarn to install it by from our filesystem
{
"devDependencies": {
"ts-remove-usestate": "file://<full_path_to_the_plugin_in_your_file_system>"
}
}
Install it by running
yarn
Now lets Reload the VS Code window by executing
> Developer: Reload Window
It's also important to choose the right Typescript version to test your plugin. Make sure to choose the workspace version of typescript by executing the following command in VS code command pallette
> Typescript: Select Typescript Version
Select the workspace version of the typescript.
Now check the TS server log. You should see the message from our plugin
Hello from remove use state!
If everything went out well , you should see the above message.
Yay! our plugin works!!
Now that our plugin is enabled , lets go ahead and build the desired functionaility functionality
Intercepting Go to Defintions
Go to Definition
is great VS code feature that is used by millions of developers.
With the power of typescript declarations typescript-language-server
redirects us to the right definition for functions, classes, variables, interfaces, types etc when Go to Definition
is fired
Now in our plugin we are going to intercept the Go to definition
action by taking leverage of the getDefinitionAndBoundSpan
method avaiable on the proxy instance we created earlier
proxy.getDefinitionAndBoundSpan = (fileName, position) => {};
The getDefinitionAndBoundSpan
method recieves the fileName
of VS code document that is active in your editor and the position where the Go to Definition
was triggered
Now ts server expects us to return meaningful definition references in the shape defined by getDefinitionAndBoundSpan
We can obtain the prior definitions by getting it from the language service like the following
proxy.getDefinitionAndBoundSpan = (fileName, position) => {
const prior = info.languageService.getDefinitionAndBoundSpan(
fileName,
position
);
return prior;
};
A list of definitions
is inside prior.defintions
. If none are available then getDefinitionAndBoundSpan
will return undefined
hence our prior will be empty so we could just return that.
so lets handle the undefined
case before doing anything further
/// Rest of the code above
if (!prior) {
return;
}
Now our desired goal(in this Part of the post) is to do nothing when Go to Def
is triggered by useState
. Before we do that lets first see what happens when you initiate Go to Def
on useState
It should take you to the official react type definitions file and more specifically to this location in the file
/**
* Returns a stateful value, and a function to update it.
*
* @version 16.8.0
* @see https://reactjs.org/docs/hooks-reference.html#usestate
*/
function useState<S>(
initialState: S | (() => S)
): [S, Dispatch<SetStateAction<S>>];
NOTE: If you see something different than the above then you might be on different version of react so don't worry about it
Now lets tell our plugin to filter out useState
from the prior.defintions
list by doing the following
/// Rest of the code above
prior.definitions = prior.definitions?.filter((def) => def.name !== "useState");
We are filtering out definitions
with the name useState
and reassigning the new list to prior.definitions
Lets test this.
NOTE: Make sure to re install the plugin in the examples/test-ts-plugin
by doing a fresh install of node_modules. Alternative use yarn link
to avoid re installation. In anycase make sure to use the workspace version of typescript
After restarting the ts server , if you try Go to Definition
on useState
the editor will no longer take you there.
If this happens then our plugin succesfully intercepted Go to Definition
and removed useState
from the final results.
Yay!! Our job is done. We have successully managed to intercept defintion provider of VS code.
Great! Putting it all together
// src/index.ts
function init({
typescript: ts,
}: {
typescript: typeof import("typescript/lib/tsserverlibrary");
}) {
function create(info: ts.server.PluginCreateInfo) {
const proxy: ts.LanguageService = Object.create(null);
for (let k of Object.keys(info.languageService) as Array<
keyof ts.LanguageService
>) {
const x = info.languageService[k]!;
// @ts-expect-error - JS runtime trickery which is tricky to type tersely
proxy[k] = (...args: Array<{}>) => x.apply(info.languageService, args);
}
info.project.projectService.logger.info("Hello from remove use state!");
proxy.getDefinitionAndBoundSpan = (fileName, position) => {
const prior = info.languageService.getDefinitionAndBoundSpan(
fileName,
position
);
if (!prior) {
return;
}
prior.definitions = prior.definitions?.filter(
(def) => def.name !== "useState"
);
return prior;
};
return proxy;
}
return { create };
}
export = init;
Great 🤓 !! you've learnt to write typescript plugins
Learn how to activate typescript plugins using VS Code extension in Part 2 of this post