<p>There's a particular kind of guilt that lives in every JavaScript codebase. You know the one. It whispers "you should probably be using TypeScript" every time you ship a typo to production. And every time, you whisper back: "I don't have three weeks to rename 40 files and annotate everything."</p><p>Here's the good news: you don't need three weeks. You need about an hour, one config file, and zero renamed files.</p><p>This is the story of how we did exactly that in Elyra Conductor — a Tauri + Svelte 5 desktop app written in plain JavaScript — and what fell out of the walls when we did.</p><h2>Why bother, if the app works?</h2><p>Our app did work. The health check was green: no vulnerabilities, no monster files, clean git tree. But "it works" and "it's safe to change" are two very different things.</p><p>Plain JavaScript has one structural weakness: mistakes are discovered at runtime. You type <code>term.optoins</code>, the editor shrugs, and your users find the bug for you. With types, the same mistake is a red squiggle before you've even saved the file.</p><p>The usual objection is cost. A full TypeScript migration means renaming files, fixing thousands of strict-mode complaints, fighting your bundler. That's a real project, and most of us have actual features to ship.</p><p>But here's the thing almost nobody tells you: the TypeScript compiler can check plain JavaScript files. No renaming. No annotations. No build changes. You just have to ask.</p><h2>How: one file, one flag</h2><p>Drop a <code>jsconfig.json</code> in your project root:</p><pre><code class="language-json">{
  "compilerOptions": {
    "checkJs": true,
    "strict": false,
    "moduleResolution": "bundler",
    "skipLibCheck": true,
    "types": ["vite/client", "node"]
  },
  "include": ["src/**/*.js", "src/**/*.svelte"],
  "exclude": ["node_modules", "dist"]
}
</code></pre><p>That <code>"checkJs": true</code> is the whole trick. The TypeScript language server — which already runs inside your editor whether you asked for it or not — starts type-checking your <code>.js</code> files using inferred types.</p><p>And inference goes surprisingly far, because the libraries you depend on already ship type definitions. In our case, <code>@xterm/xterm</code>, <code>monaco-editor</code>, and <code>@tauri-apps/api</code> all carry their own types. The moment we flipped the flag, calls like this got checked for free:</p><pre><code class="language-js">// ❌ Now an error: fit() takes no arguments
fit.fit(123);

// ❌ Now an error: 'optoins' does not exist (did you mean 'options'?)
term.optoins.theme = myTheme;
</code></pre><p>For a Svelte project, add a CLI runner so this works in CI, not just in editors:</p><pre><code class="language-bash">pnpm add -D svelte-check typescript
</code></pre><pre><code class="language-json">{ "scripts": { "check": "svelte-check --fail-on-warnings" } }
</code></pre><h2>What it actually found (this is the fun part)</h2><p>We ran <code>pnpm check</code> for the first time on a codebase we considered healthy. Five errors. Four were the checker being pedantic. One was a real, sleeping bug.</p><h3>The real bug</h3><pre><code class="language-js">// FileExplorer.svelte — before
let visible = $derived(filterEntries(entries, showAll));

let entries = $state([]);   // 👈 declared AFTER it's used
</code></pre><p>The <code>$derived</code> rune referenced <code>entries</code> before its declaration. It happened to work because Svelte 5 evaluates derived values lazily — but it was living in the temporal dead zone, one refactor away from a <code>ReferenceError</code>. The fix was moving two lines. The checker found in seconds what code review had missed for months.</p><h3>The pedantic ones (also useful!)</h3><p>Our context menus mix plain items with separators:</p><pre><code class="language-js">items.push(
  { separator: true },
  { label: "Move to Trash", danger: true, action: () =&gt; deleteEntry(e) },
);
</code></pre><p>TypeScript inferred the array type from the first element and complained about the rest. Not a bug — but the fix made the code better, because a JSDoc typedef doubles as documentation and autocomplete:</p><pre><code class="language-js">/** @typedef {{ label?: string, icon?: string, action?: () =&gt; void, danger?: boolean, separator?: boolean }} MenuItem */

/** @type {MenuItem[]} */
const items = [ /* ... */ ];
</code></pre><p>Still a <code>.js</code> file. Still zero build changes. But now the editor knows what a menu item looks like.</p><h3>The bonus round: 44 warnings</h3><p><code>svelte-check</code> doesn't just type-check — it surfaces Svelte's own accessibility and reactivity warnings. Ours revealed a pattern repeated across fourteen modal dialogs:</p><pre><code class="language-svelte">&lt;!-- before: close-on-outside-click via propagation hack --&gt;
&lt;div class="overlay" onclick={onclose}&gt;
  &lt;div class="modal" role="dialog" onclick={(e) =&gt; e.stopPropagation()}&gt;
</code></pre><p>It works, but the dialog can't receive focus (an ARIA requirement), and <code>stopPropagation</code> is a foot-gun waiting for the day something else needs that event. The cleaner version:</p><pre><code class="language-svelte">&lt;!-- after: check the click actually hit the overlay --&gt;
&lt;div class="overlay" onclick={(e) =&gt; e.target === e.currentTarget &amp;&amp; onclose()}&gt;
  &lt;div class="modal" role="dialog" tabindex="-1"&gt;
</code></pre><p>Same behavior, no hack, and screen readers are happier. We fixed all 44 warnings in an afternoon — then locked the door behind us.</p><h2>Locking the door: the quality gate</h2><p>Getting to zero is satisfying. Staying at zero is the actual win. Two lines in our release script:</p><pre><code class="language-bash">echo "==&gt; Quality gate: svelte-check (type + a11y)"
pnpm check   # set -e aborts the release on any finding
</code></pre><p>And the crucial flag in the check script: <code>--fail-on-warnings</code>. By default <code>svelte-check</code> only fails on errors — warnings would quietly pile up again. Since we're at 0/0, we keep it there. Now a release physically cannot ship with a known type or accessibility issue. The gate runs before signing, before notarization, before anything expensive.</p><h2>The honest cost-benefit</h2><p>What it cost us: one config file, three dev dependencies, an afternoon of fixing warnings we genuinely wanted fixed anyway.</p><p>What we got:</p><ul><li><p>A real bug fixed before any user hit it</p></li><li><p>Red squiggles + autocomplete in plain <code>.js</code> files, powered by our dependencies' own types</p></li><li><p>Fourteen more accessible dialogs</p></li><li><p>A release pipeline that refuses to regress</p></li></ul><p>What we didn't have to do: rename a single file, write a single type annotation we didn't want to, or change one line of build config.</p><p>That's the whole pitch for gradual adoption. It's not a migration — it's turning on a smoke detector in a house you already live in. You can convert files to <code>.ts</code> later, one at a time, whenever you happen to be in them. Or never. The smoke detector works either way.</p>