When Vue 2.0 and TypeScript 2.0 were released in 2016, people wanted to use them together.

The sad thing was they couldn’t. You could make Vue and TS code compile together, but not without a lot of hassle. It’s neither elegant (what Vue is all about) nor type-safe (what TypeScript is all about). With Vue 2.5 shipping typing definition and Vue CLI 3’s improvements, it has become easier to setup a project with Vue and TypeScript. However, type-safety wise, there’s still a lot to be desired.

One thing people really wanted most was template type checking, and this popular blog post written by HerringtonDarkholme, a Vue and TypeScript contributor, accurately summarized the situation:

Well, the statement is no longer true since Vetur’s 0.19.0 release:

These Language Server Protocol language features become available for Vue interpolation expressions:

You can read more about how this feature works in Vetur’s documentation. Other Language Clients using VLS (Vue Language Server) >= 0.0.50 should get those features too.

This post explains how this feature was implemented.

Vetur

Vetur is the VS Code extension for working with Vue files. It is a language server extension hosting the Vue Language Server, which combines HTML / CSS / TypeScript / Stylus language servers to support the many embedded languages in Vue Single File Component. You can read more about its features in Vetur documentation.

First, a rough explanation of how Vetur’s JS / TS / Vue support works:

Vetur builds on top of TypeScript’s Language Server API. To make TypeScript understand Vue files, Vetur masks .vue files as TS files. For example, when you see a Single File Component (SFC) like this:

<template>
  <div></div>
</template>
<script>
console.log('Vetur')
</script>

The TypeScript Language Service in Vetur sees this ( stands for space):

██████████
█████████████
███████████
████████
console.log('Vetur')
█████████

Vetur then proxies all LSP request to and responses from TypeSript Language Service to the SFC to make language features work on SFC. As both files share same position of the actual TS code, the mapping is easy.

The Idea

In 2017, Vue core team member Katashin opened an issue to discuss template expression type-checking. The proposed approach was:

This sounds like a lot of work and resource heavy, so I thought it’s never gonna happen. However, almost a year later, Katashin opened a Pull Request to implement this feature. It more or less worked.

For the <template> region in the above code, the transformed TypeScript code looks roughly like this:

import __Component from "./Component.vue";
import { __vlsRenderHelper, __vlsComponentHelper } from "vue-editor-bridge";
__vlsRenderHelper(__Component, function () {
    __vlsComponentHelper("div", { props: {}, on: {}, directives: [] }, [this.msg]);
});

The __Component refers to the SFC itself, which Vetur could resolve. The __vls helpers contextually type this in the function context:

export declare const __vlsRenderHelper: {
  <T>(Component: (new (...args: any[]) => T), fn: (this: T) => any): any;
};

When you type m inside the interpolation region:

This sounds all good, but at that time Vetur suffered from performance issues. Doing this seems to require spinning up another TypeScript Language Service. I supposed this would double the CPU / memory usage of Vetur, so I left the PR languish for quite some time.

The other reason that I didn’t merge the PR in immediately was that I would want language features to “just work” inside the template interpolation regions. The PR only implements diagnostics, but I wanted to make sure the way it’s implemented would pave way for any language features.

Picking Up The Idea

After graduating in 2017 and then joining Microsoft, I haven’t had much time to work on Vetur. However, this year I managed to get three months to work full-time on Vetur (that’s why you see Vetur’s milestone on the VS Code iteration plan).

Looking back at the PR in 2019, I realized a few things:

So, in preparation, I started building Template Interpolation Completion with another approach (traversing the TypeScript AST in the original .vue file and generating the completion items myself), decoupled Vetur from a fixed version of TypeScript, and upgraded Vetur to use TypeScript 3.3.

Then I rebased the original PR off the current master. However, things aren’t all rosy. I ran into quite some problems.

And for the sake of posterity, here are the details. Unless you are deep into Language Servers and TypeScript, this wouldn’t make much sense.

Problems

⚠️ Lots of Language Server Protocol and TypeScript jargons ahead. ⚠️

When playing around with the PR, the two biggest issue I had were:

After some digging, I found that the cause was that the virtually transformed TS file didn’t satisfy certain constraints on ts.SourceFile.

Normally, when you call ts.createSourceFile on a string of valid TypeScript code, you get a TS AST that has valid Nodes. Each Node has valid position. Each ParentNode has a range that neatly covers all its children’s ranges. TypeScript Language Service works well off those valid SourceFiles.

Enter the hooligan virtual TS files whose Nodes are all synthetic (manually created with ts.create... call) and who have invalid positioning like (-1, -1). The only few ts.Expression Nodes that have above zero positions have their positions set to the same position as the one in SFC.

For example, in the virtual file, this.msg could have a position like (50, 53) despite it length is 7, and despite 200 instead of 50 characters of TS code precede it. Of course TypeScript Language Service would hate them and crash in protest.

The only saint in the TypeScript namespace that could work with this hooligan virtual file is ts.createPrinter. The printer prints each Node sequentially, ignoring its position and only care about its syntax kind. So the new idea is:

  1. Transform the ESLint AST (we use eslint-plugin-vue’s parser to parse Vue templates) into TS AST in the invalid virtual file
    1. For each expression transformed into ts.Node, use ts.setSourceMapRange to set its source range. I just need a place to record the range of the original expression in SFC
  2. Print that invalid virtual file to get valid TS code
  3. Create a valid SourceFile from that valid TS code
  4. Create a sourcemap from original SFC to the final, valid virtual file
    1. Walk through the AST of both the invalid/valid virtual files. They should share the same structure.
    2. When a Node from the invalid virtual file has a source map range (from 1.1), create a mapping from that source map range to the range of the corresponding Node in the valid virtual file.
    3. Do some special handling, since I would map msg to this.msg. When translating position from SFC to the virtual file, I would map positions like this: |msg => this.|msg, but when a diagnostic error occurs on this.msg in the virtual file, I map back both these positions |this.msg and this.|msg to the position of |msg.

An example of what’s happening in step 4:

After handling a few edge cases, this works well enough so I’m releasing it.

Some Todos

My next focus is to improve the CPU / memory usage of Vetur on large projects. However, there are a lot of nice things that could happen:

I’ll try to get to these things, but if you are interested in making some of these features happen, the source code is at vuejs/vetur. Contribution welcome!

Thanks

First and foremost, thanks to the amazing work Katashin has done!

However, this feature wouldn’t be possible without many people’s work behind the scenes:

#tech