Nikhil Verma

How I am using Vue with Typescript 7 today

TypeScript 7 shipped this week. The Go-native compiler, the one with the order-of-magnitude speed claims, is now just typescript@7 on npm. And buried in the announcement is a caveat that reads like bad news for Vue developers: tools that embed TypeScript into their own language services — Volar among them — can’t use it yet, so “workflows that use Vue, MDX, Astro, Svelte and others will likely not yet be able to leverage TypeScript 7.”

Our Vue app has been on the native compiler the whole time. Every component, type-checked by TypeScript 7’s toolchain, on GA week, no plugins, no waiting for anything to be ported.

We write Vue in .tsx files. The reason that works points at a misconception about what Vue actually is, and the payoff goes well beyond one fast compiler.

The .vue file was always optional

Say “Vue” and most people picture a .vue file: a <template> block, a <script setup> block, maybe some scoped styles. The single-file component is Vue’s signature authoring format, it’s what every tutorial teaches, and it’s genuinely pleasant. Somewhere along the way, the public assumption calcified into Vue requires .vue files.

It doesn’t, and Vue has never claimed it does. The docs describe render functions and JSX as a fully supported, first-class way to write components. Vue ships a JSX transform, a jsxImportSource, and complete JSX type definitions out of the box. The SFC is sugar over a component model that is plain JavaScript: an object with a setup function that wires up reactive state and returns a function that renders it.

Which means a Vue component can be an ordinary TypeScript file:

import { defineComponent, ref } from "vue";

export default defineComponent({
 name: "UsageBadge",
 props: {
  label: { type: String, required: true },
  limit: { type: Number, default: 100 }
 },
 setup(props) {
  const used = ref(0);
  const bump = () => used.value++;

  return () => (
   <button class="badge" onClick={bump}>
    {props.label}: {used.value}/{props.limit}
   </button>
  );
 }
});

Everything here is documented Vue. setup runs once per component instance and closes over the reactive state; the arrow function it returns is the render function, and Vue re-runs that — and only that — when something it read changes. This shape is more honest about Vue’s execution model than the SFC is: the one-time setup and the re-running render are two visibly different functions, rather than a compiler convention you have to know about.

The configuration to make TypeScript understand it is two lines:

{
 "compilerOptions": {
  "jsx": "preserve",
  "jsxImportSource": "vue"
 }
}

There’s no preprocessor and no custom language service to install. A .tsx file with Vue as its JSX source is just TypeScript, and every tool that speaks TypeScript — the compiler, the linter, your editor, and as of this week a very fast Go binary — handles it natively.

The heroics you no longer need

To appreciate what “just TypeScript” buys you, look at what it takes to type-check the file format everyone assumes is mandatory.

TypeScript has no idea what a .vue file is. So Volar — the language tooling behind Vue — performs a genuinely impressive maneuver: it intercepts TypeScript’s view of the filesystem, and wherever the compiler would read a .vue file, Volar hands it generated TypeScript instead. Your template becomes synthesized code full of type-level gymnastics; source maps stitch the compiler’s errors back onto your template expressions. On the editor side you need the Vue extension to make VS Code play along, and for a while in 2024 you needed to understand its “hybrid mode” — which half of the language service answered which kind of request — just to work out why autocomplete had stopped showing suggestions.

This is admirable engineering. Volar is one of the most technically interesting projects in the frontend ecosystem, and for SFC codebases it works well. But it is a shim layer, and shim layers have a cost that shows up on the margins: a squiggle that lands a line off because a source map didn’t quite align, a rename refactor that doesn’t reach into templates, a new TypeScript release that the shim has to chase. This week is the sharpest example yet — TypeScript 7 doesn’t expose the API surface the shim hooks into, so the entire .vue world waits for TypeScript 7.1 and a Volar port before it sees any of the new compiler’s speed.

None of this is a criticism of Vue. It’s the structural price of any custom file format, and Svelte, Astro and MDX are all in the same waiting room. Vue is the one framework in that list that doesn’t make you pay it. The escape hatch is documented, and — we can now report from production — entirely boring to live with.

The reactivity system comes with you

The reflex objection: isn’t this just React with extra steps? If I’m writing JSX anyway, what’s left of Vue?

The answer is everything that matters, because Vue’s soul is the reactivity system. The difference is easiest to see in the code you don’t write.

In React, the runtime re-runs your whole component function on every update and cannot know what your effects depend on, so you tell it, by hand, forever:

const total = useMemo(
 () => items.reduce((sum, i) => sum + i.price * i.qty, 0),
 [items] // you maintain this list. every hook. every edit.
);

useEffect(() => {
 document.title = `Cart: ${total}`;
}, [total]);

Every React developer knows the failure modes: a stale closure because a dependency was missed, a render loop because an object identity changed, an exhaustive-deps lint rule arguing with you about what you meant. It’s manual memory management for dataflow.

Vue tracks dependencies at runtime, automatically. A computed or a watchEffect records exactly which reactive values it read while running; when one of those changes, it re-runs. There is no list to maintain because the system derives the list from the truth of what executed:

const items = ref([{ price: 1, qty: 2 }]); // deeply reactive by default

const total = computed(() =>
 items.value.reduce((sum, i) => sum + i.price * i.qty, 0)
);

watchEffect(() => {
 document.title = `Cart: ${total.value}`;
});

No dependency arrays, no useCallback rituals. And because setup runs once, you don’t re-create closures on every render or reach for useRef to smuggle values past the re-render boundary — state just lives in scope, and the render closure reads it.

Writing this in TSX changes none of it. The reactivity and the automatic tracking come from the runtime you import from "vue"; the file extension has no say in it. You keep the framework’s superpower and hand the authoring problem to the most heavily-tooled language format in the industry.

There are a few habits to pick up — slots become object literals of functions, v-model becomes an explicit prop-plus-handler pair, and strongly-typed wrapper libraries sometimes want a props spread — but each is a small convention you learn in a day. All three make previously-implicit machinery explicit, which our code reviews turned out to appreciate.

The proof: TypeScript 7, in production, this week

The compliance platform I work on is a Nuxt app with roughly eight hundred TSX components — pages, layouts, the app root, all of it plain TSX with jsxImportSource: "vue". Nuxt wires up the JSX transform automatically.

Because every component is real TypeScript, our type-checking pipeline runs on the native toolchain — the same Go compiler that GA’d as TypeScript 7 — and has for months, across the preview releases. A full type-aware pass over the entire app takes about 46 seconds on a laptop. When the GA landed this week, our upgrade was a version bump in one package.json. The components didn’t know anything happened.

Meanwhile the .vue path, through no fault of its own, is on TypeScript 6 until the 7.1 API ships and Volar rebuilds on top of it — “at least several months,” per the TypeScript team, with no committed date. I don’t say that to gloat; the port will land and SFC users will get their speedup too. But the gap between the two paths was always there, and TypeScript 7 just made it visible as a version number.

The migration, briefly

We didn’t start here. We started as a normal SFC codebase, and moved to TSX in one big-bang change this spring: about 1,500 files in a single PR. The mechanical bulk was done by a converter I open-sourced as vue-to-tsx — it parses the SFC with Vue’s own compiler and rewrites templates to JSX render functions, <script setup> macros to defineComponent, scoped styles to plain CSS imports. Directives with no JSX equivalent get flagged for a human (or, optionally, an LLM) to resolve. It converts a file like this:

<template>
 <button @click="emit('update', count)">Count: {{ count }}</button>
</template>
<script setup lang="ts">
import { ref } from "vue";
const props = defineProps<{ initial?: number }>();
const emit = defineEmits<{ (e: "update", value: number): void }>();
const count = ref(props.initial ?? 0);
</script>

into this:

import { defineComponent, ref } from "vue";

export default defineComponent({
 emits: ["update"],
 setup(props, { emit }) {
  const count = ref(props.initial ?? 0);
  return () => (
   <button onClick={() => emit("update", count.value)}>
    Count: {count.value}
   </button>
  );
 }
});

A handful of .vue files survive in our repo — vendored UI-library primitives we treat as third-party code, plus a couple of components leaning hard on scoped styles. The goal was to move the codebase’s center of gravity, and it moved.

The migration’s original motivation wasn’t TypeScript 7 at all. We did it because we wanted components that read as plain code and tooling that didn’t need a translation layer. The native-compiler dividend arrived months later, unearned, precisely because we were sitting on the mainline TypeScript path when it got faster. That’s the property I’d sell harder than any benchmark: standard formats appreciate in value while you sleep.

The takeaway

Don’t tie Vue to .vue. The framework’s best ideas — reactivity with automatic dependency tracking, effects the runtime manages for you — live in the runtime and travel intact into plain TSX. The single-file component remains a great format, and if it’s serving you well, nothing here says to move.

But if you’ve ever assumed Vue can’t work without its custom files, or dismissed it because you didn’t want a bespoke toolchain between you and your type checker: that assumption is wrong. The path is documented and proven in production, and as of this week it comes with the fastest TypeScript compiler ever shipped. You can use Vue in a modern, native way — today.