VS Code Already Type-Checks Your JavaScript. You Just Have to Let It.

Three moves to get TypeScript-grade safety in a plain JS project. No rewrite required.
You Cmd+Click on a function expecting to jump to its definition. Nothing. You hover over a variable expecting types. No tooltip. Auto-import suggests a helper from migrations/0421_seed_legacy_users.js that you've never opened.
Cmd+Shift+P → Restart TS Server. The fan spins up. IntelliSense comes back. For a while.
This isn't a VS Code bug. The thing in front of you is a real TypeScript compiler — one you didn't know was running — and you're using maybe 10% of what it's capable of. The fix isn't a rewrite to TypeScript. The fix is to let the compiler already on your machine do its job.
VS Code is a TypeScript compiler running on your JavaScript
Most JS developers think VS Code's IntelliSense is some fuzzy, grep-flavored thing. It isn't.
VS Code's JS IntelliSense is powered by tsserver — the TypeScript language service. It is the exact same compiler TypeScript itself uses, running on your .js files in inferred-types mode. When you open a project, this is what tsserver does:
Walks the entire directory tree looking for
.jsfiles.Parses every one into an AST and holds it in memory.
Builds a symbol table for every exported name across the project.
Runs control-flow type inference through imports.
Indexes every exported symbol as a candidate for auto-import.
A real compiler with real state. Not a search tool. It maintains a working model of your entire codebase, updated on every keystroke.
Once you have that mental model, three things follow. The compiler is doing work for you whether you benefit from it or not. It can type-check your JS as strictly as real TypeScript — but only if you opt in. And it can read your runtime validation library, so the shape you check at runtime and the shape you check at compile time can be the same shape.
You don't need to rewrite anything in TypeScript to get most of TypeScript's safety. You just need to turn it on.
Prerequisite: stop drowning the compiler
A quick housekeeping note before the main content.
In a big repo, tsserver parses everything by default — including migrations/, locale/, legacy-test/, and 30,000 other files it has no business looking at. Memory bloats to 2-3 GB. Garbage collection takes over. The compiler either crashes outright or stays alive but spends so much time in GC that VS Code's IntelliSense requests time out. Either way, you stop getting types.
Most senior JS devs already know the fix. If you don't:
// jsconfig.json
{
"compilerOptions": { "baseUrl": ".", "module": "commonjs" },
"exclude": [
"**/node_modules",
"**/dist",
"**/build",
"migrations",
"locale",
"legacy-test",
"fixtures"
]
}
Mirror the same paths into .vscode/settings.json under search.exclude and files.watcherExclude — those are separate indexes that need separate exclude lists.
That's it for the housekeeping. Without an exclude config, tsserver is dead before it can check anything; everything below assumes the compiler is actually running.
Move 1: // @ts-check — opt into real type checking
By default, tsserver on a .js file gives you autocomplete and basic type hints. That's a small fraction of what it can do.
Put this at the top of any .js file:
// @ts-check
That one-line comment tells tsserver to type-check this file as if it were TypeScript. It uses inference where it can, and JSDoc comments where you've annotated. Suddenly you get red squiggles on real bugs:
Passing a
stringwhere anumberis expectedCalling
.toLowerCase()on something that might beundefinedTypos in property names (
user.emial)Missing
awaiton a PromiseUnreachable branches
Combined with JSDoc annotations, the checking gets sharper:
// @ts-check
/**
* @param {{ id: number, email: string }} user
* @returns {Promise<boolean>}
*/
async function isVerified(user) {
return await db.verified(user.id);
}
Now isVerified({ id: "abc" }) is a hard error in your editor. The compiler reads your JSDoc as if it were TypeScript types — because under the hood, it is TypeScript types. No rename to .ts, no build step, no migration.
For a whole-project version, add "checkJs": true to your jsconfig.json's compilerOptions. Be warned: turning this on across an old codebase will surface hundreds of warnings on day one. File-by-file opt-in via // @ts-check is usually the saner path.
This single comment is the highest-ROI line of code you can add to a legacy JS file.
Move 2: Zod + JSDoc — one shape, runtime and compile time
If you parse data anywhere — API request bodies, environment variables, form submissions, config files — you have two shapes for the same thing. One in your validator, one in your JSDoc:
/**
* @typedef {Object} User
* @property {number} id
* @property {string} email
* @property {"admin" | "member"} role
*/
const userSchema = z.object({
id: z.number(),
email: z.string().email(),
role: z.enum(["admin", "member"]),
});
Two definitions of User. Two places to update. Guaranteed to drift.
If you're using Zod, there's a one-line fix that works in pure JS — no TypeScript file required:
// @ts-check
import { z } from "zod";
const userSchema = z.object({
id: z.number(),
email: z.string().email(),
role: z.enum(["admin", "member"]),
});
/** @typedef {z.infer<typeof userSchema>} User */
That @typedef line creates a JSDoc type named User whose definition is whatever userSchema.parse() would return at runtime. tsserver reads this in your .js file exactly the way it would read type User = z.infer<typeof userSchema> in a .ts file.
Now you can use User as a JSDoc type everywhere:
/**
* @param {User} user
*/
function welcome(user) {
return `Welcome, ${user.email}`;
// ^? string — fully typed
}
welcome({ id: "abc", email: "x" });
// Error: Type 'string' is not assignable to type 'number'.
// Property 'role' is missing.
One schema. Runtime validation at your boundaries, compile-time types throughout your code. Add a field to the schema and the type updates automatically. Remove a field and every usage site lights up red.
The three places this pays off most:
// API request body
const createUserBody = z.object({
email: z.string().email(),
name: z.string(),
});
/** @typedef {z.infer<typeof createUserBody>} CreateUserBody */
app.post("/users", (req, res) => {
/** @type {CreateUserBody} */
const body = createUserBody.parse(req.body);
// validated AND typed
});
// Environment variables
const envSchema = z.object({
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().default(3000),
});
const env = envSchema.parse(process.env);
// `env` is typed as { DATABASE_URL: string; PORT: number }
// No more `process.env.PORT` returning string | undefined
// Form schemas
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
/** @typedef {z.infer<typeof loginSchema>} LoginForm */
One schema, used twice: parsed at the boundary, typed everywhere else. No drift possible, because there's only one place to update.
The point
VS Code was always going to type-check your JavaScript. The compiler is sitting right there, in your editor, parsing every file you open.
Three moves and you're using it:
Add an
excludetojsconfig.jsonso the compiler doesn't choke on files that don't matter.Add
// @ts-checkto the files that do matter — or"checkJs": truefor the whole project.Use
z.inferinside a JSDoc@typedefso your runtime validators and your types are the same shape.
No .ts rename. No build configuration. No migration. Just turn on the compiler that was already running.
One honest caveat
Everything above happens inside the editor. The squiggles are real, but they don't fail your CI. You can't run tsc --noEmit against a .js project to enforce types in a pull request. Your build pipeline doesn't know any of this is happening. A teammate who turns off // @ts-check on a file gets no friction from the system.
If you want type errors to block code from merging — not just appear in your editor — TypeScript itself is the answer. JSDoc-based checking also gets verbose for anything beyond basic shapes; once you reach for generics or mapped types, .ts syntax pays for itself.
The good news: you don't have to migrate everything at once. TypeScript supports incremental adoption. You can rename one file from .js to .ts, fix its errors, and ship — while the rest of the codebase stays in JavaScript. The three moves in this post are still useful during the migration: @ts-check files are easy to convert to .ts because most of the work is already done.
Start here. If you outgrow it, move to TypeScript one file at a time.
