Paying Down Tech Debt Before Coffee Gets Cold: Gradual TypeScript for a Plain JavaScript App - Elyra Conductor v.0.7.6
You don't need three weeks to adopt TypeScript — you need an hour, one config file, and zero renamed files. Here's how we turned on type-checking in a plain-JS Tauri + Svelte app, what fell out of the walls, and how we locked the door behind us.
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."
Here's the good news: you don't need three weeks. You need about an hour, one config file, and zero renamed files.
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.
Why bother, if the app works?
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.
Plain JavaScript has one structural weakness: mistakes are discovered at runtime. You type term.optoins, 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.
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.
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.
How: one file, one flag
Drop a jsconfig.json in your project root:
{
"compilerOptions": {
"checkJs": true,
"strict": false,
"moduleResolution": "bundler",
"skipLibCheck": true,
"types": ["vite/client", "node"]
},
"include": ["src/**/*.js", "src/**/*.svelte"],
"exclude": ["node_modules", "dist"]
}
That "checkJs": true 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 .js files using inferred types.
And inference goes surprisingly far, because the libraries you depend on already ship type definitions. In our case, @xterm/xterm, monaco-editor, and @tauri-apps/api all carry their own types. The moment we flipped the flag, calls like this got checked for free:
// ❌ 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;
For a Svelte project, add a CLI runner so this works in CI, not just in editors:
pnpm add -D svelte-check typescript
{ "scripts": { "check": "svelte-check --fail-on-warnings" } }
What it actually found (this is the fun part)
We ran pnpm check for the first time on a codebase we considered healthy. Five errors. Four were the checker being pedantic. One was a real, sleeping bug.
The real bug
// FileExplorer.svelte — before
let visible = $derived(filterEntries(entries, showAll));
let entries = $state([]); // 👈 declared AFTER it's used
The $derived rune referenced entries 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 ReferenceError. The fix was moving two lines. The checker found in seconds what code review had missed for months.
The pedantic ones (also useful!)
Our context menus mix plain items with separators:
items.push(
{ separator: true },
{ label: "Move to Trash", danger: true, action: () => deleteEntry(e) },
);
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:
/** @typedef {{ label?: string, icon?: string, action?: () => void, danger?: boolean, separator?: boolean }} MenuItem */
/** @type {MenuItem[]} /
const items = [ / ... */ ];
Still a .js file. Still zero build changes. But now the editor knows what a menu item looks like.
The bonus round: 44 warnings
svelte-check doesn't just type-check — it surfaces Svelte's own accessibility and reactivity warnings. Ours revealed a pattern repeated across fourteen modal dialogs:
<!-- before: close-on-outside-click via propagation hack -->
<div class="overlay" onclick={onclose}>
<div class="modal" role="dialog" onclick={(e) => e.stopPropagation()}>
It works, but the dialog can't receive focus (an ARIA requirement), and stopPropagation is a foot-gun waiting for the day something else needs that event. The cleaner version:
<!-- after: check the click actually hit the overlay -->
<div class="overlay" onclick={(e) => e.target === e.currentTarget && onclose()}>
<div class="modal" role="dialog" tabindex="-1">
Same behavior, no hack, and screen readers are happier. We fixed all 44 warnings in an afternoon — then locked the door behind us.
Locking the door: the quality gate
Getting to zero is satisfying. Staying at zero is the actual win. Two lines in our release script:
echo "==> Quality gate: svelte-check (type + a11y)"
pnpm check # set -e aborts the release on any finding
And the crucial flag in the check script: --fail-on-warnings. By default svelte-check 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.
The honest cost-benefit
What it cost us: one config file, three dev dependencies, an afternoon of fixing warnings we genuinely wanted fixed anyway.
What we got:
A real bug fixed before any user hit it
Red squiggles + autocomplete in plain
.jsfiles, powered by our dependencies' own typesFourteen more accessible dialogs
A release pipeline that refuses to regress
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.
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 .ts later, one at a time, whenever you happen to be in them. Or never. The smoke detector works either way.