Move a JavaScript Folder to TypeScript Without a Big Bang
Convert a folder one file at a time while the build and the tests stay green, starting from the files that import nothing. After this, a migration is a series of small commits that anyone can review.
Course content
Sign in to save your progress.
-
Types That Compute
-
Conditional Types and infer Without Nested Nightmares
-
Template Literal Types for Routes and Event Names
-
-
-
Brands and Boundaries
-
Branded Types So IDs Don't Mix
-
satisfies and const Assertions When Inference Isn't Enough
-
-
-
Declare What JavaScript Didn't
-
Writing .d.ts for a Plain JS Module
-
Ambient Modules and Global Augmentation Without Breaking Everything
-
-
-
Projects That Scale
-
Project References and Solution-Style tsconfig
-
typescript-eslint Rules That Catch Real Mistakes
-
-
-
Migrate and Keep Moving
-
Move a JavaScript Folder to TypeScript Without a Big Bang
-
allowJs, checkJs and Leaving JSDoc Behind
-
Keep Build Times Sane: incremental and What Actually Slows tsc
-
-
- Final Check: TypeScript Advanced
Skip ahead: If you already have a mixed JavaScript and TypeScript build and only want the order to convert files in, skip to "Find the leaves", then continue.
Renaming every JavaScript file at once creates a large migration to review. Type errors, runtime changes and other work on the project can become mixed together, making failures harder to trace. This lesson converts one file at a time and checks the program after each step. Keep each step small enough to review, test and pause before continuing.
The starting point
A small pricing folder in plain JavaScript. It has one latent bug, and you will see TypeScript find it.
{ "name": "shop", "private": true, "type": "module" }
export function formatMoney(amount, currency) {
return `${currency} ${amount.toFixed(2)}`;
}
export function round2(n) {
return Math.round(n * 100) / 100;
}
import { round2 } from "./money.js";
export const RATE = 0.12;
export function withTax(amount) {
return round2(amount * (1 + RATE));
}
import { formatMoney } from "./money.js";
import { withTax } from "./tax.js";
export function addItem(items, name, price, qty) {
items.push({ name, price, qty });
return items;
}
export function total(items) {
return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
export function receipt(items) {
return formatMoney(withTax(total(items)), "PHP");
}
import { addItem, receipt } from "./cart.js";
const items = [];
addItem(items, "pen", 12, 3);
addItem(items, "pad", 40);
console.log(receipt(items));
node src/index.js
PHP NaN
PHP NaN is what a customer would see. The second addItem call leaves out the quantity, so item.qty is undefined and 40 * undefined is NaN. Keep that in mind.
Step 1: a build that accepts both
Before you convert anything, make the compiler build the JavaScript folder as it is. The point is to prove the pipeline first, when there is nothing else that could be wrong.
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"strict": true,
"allowJs": true,
"checkJs": false,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
npx tsc -p .
echo "tsc exit code $?"
node dist/index.js
tsc exit code 0
PHP NaN
Same output, from the built copy. checkJs is off on purpose, so the compiler copies the JavaScript and reports nothing. From here on you run dist/, not src/. Commit this step by itself. If it fails in your repository (a bundler, a test runner, a different module system), you have learned that before you touched a single file.
Find the leaves
A leaf is a file that imports nothing from your own project. Convert leaves first, because a converted file gives types to everyone who imports it, and a leaf has no dependencies that could make its errors confusing.
grep -n 'from "\./' src/*.js
src/cart.js:1:import { formatMoney } from "./money.js";
src/cart.js:2:import { withTax } from "./tax.js";
src/index.js:1:import { addItem, receipt } from "./cart.js";
src/tax.js:1:import { round2 } from "./money.js";
money.js imports nothing, so it is the leaf. tax.js depends on money.js, cart.js on both, and index.js on cart.js. That gives you the order: money, tax, cart, index. In a bigger repository this list is long, and a script that sorts files by the number of project imports is worth ten minutes of your time.
Step 2: rename one file
Rename the leaf. Do not change anything else in the same commit.
mv src/money.js src/money.ts
npx tsc -p .
src/money.ts(1,29): error TS7006: Parameter 'amount' implicitly has an 'any' type.
src/money.ts(1,37): error TS7006: Parameter 'currency' implicitly has an 'any' type.
src/money.ts(5,24): error TS7006: Parameter 'n' implicitly has an 'any' type.
Three errors, all of one kind: a parameter with no type. In a .js file the compiler did not mind. In a .ts file under strict, an untyped parameter is an error. This is the whole migration in miniature. Add the types.
export function formatMoney(amount: number, currency: string): string {
return `${currency} ${amount.toFixed(2)}`;
}
export function round2(n: number): number {
return Math.round(n * 100) / 100;
}
npx tsc -p .
echo "tsc exit code $?"
node dist/index.js
tsc exit code 0
PHP NaN
Still PHP NaN, and the build is clean. The other three files are still JavaScript and nothing in them changed. They import ./money.js, which is now built from a TypeScript file, and they do not care. Commit.
Step 3: the next file finds the bug
tax.js is the same as money.js: rename it, type the parameter and the constant, build. Then do cart.js, where something more interesting happens. Give it types and keep the logic exactly as it was.
mv src/tax.js src/tax.ts
import { round2 } from "./money.js";
export const RATE = 0.12;
export function withTax(amount: number): number {
return round2(amount * (1 + RATE));
}
mv src/cart.js src/cart.ts
import { formatMoney } from "./money.js";
import { withTax } from "./tax.js";
export interface Item {
name: string;
price: number;
qty?: number;
}
export function addItem(items: Item[], name: string, price: number, qty?: number): Item[] {
items.push({ name, price, qty });
return items;
}
export function total(items: Item[]): number {
return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
export function receipt(items: Item[]): string {
return formatMoney(withTax(total(items)), "PHP");
}
npx tsc -p .
src/cart.ts(16,57): error TS18048: 'item.qty' is possibly 'undefined'.
That is the bug, found without running the program. Writing qty?: number was an honest description of how addItem is called, and the compiler followed it through to the multiplication. The fix is a decision, not a type: an item with no quantity is one of it.
import { formatMoney } from "./money.js";
import { withTax } from "./tax.js";
export interface Item {
name: string;
price: number;
qty?: number;
}
export function addItem(items: Item[], name: string, price: number, qty = 1): Item[] {
items.push({ name, price, qty });
return items;
}
export function total(items: Item[]): number {
return items.reduce((sum, item) => sum + item.price * (item.qty ?? 1), 0);
}
export function receipt(items: Item[]): string {
return formatMoney(withTax(total(items)), "PHP");
}
npx tsc -p .
echo "tsc exit code $?"
node dist/index.js
tsc exit code 0
PHP 85.12
⚠ Warning: Fixing a bug and converting a file in the same commit makes both harder to review. If the behaviour changes, say so in the commit message. A migration commit that changes the output of the program should be the exception, and the reviewer should be told.
Step 4: finish and count
index.js is last. Rename it and build.
mv src/index.js src/index.ts
npx tsc -p .
src/index.ts(3,7): error TS7034: Variable 'items' implicitly has type 'any[]' in some locations where its type cannot be determined.
src/index.ts(4,9): error TS7005: Variable 'items' implicitly has an 'any[]' type.
src/index.ts(5,9): error TS7005: Variable 'items' implicitly has an 'any[]' type.
src/index.ts(7,21): error TS7005: Variable 'items' implicitly has an 'any[]' type.
An empty array literal has no element type for the compiler to infer, and under strict that is an error at the declaration and at every use. It is the same kind of problem as the untyped parameters, and the fix is the same: say what goes in the array.
import { addItem, receipt, type Item } from "./cart.js";
const items: Item[] = [];
addItem(items, "pen", 12, 3);
addItem(items, "pad", 40);
console.log(receipt(items));
Now no .js file is left in src, so turn allowJs off. From this point a stray .js file is ignored by the build, and nobody can add one by accident.
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"strict": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
rm -rf dist
npx tsc -p .
echo "tsc exit code $?"
node dist/index.js
find src -name "*.js" | wc -l
find src -name "*.ts" | wc -l
tsc exit code 0
PHP 85.12
0
4
Four files converted, none left. Throughout, the number of .js files in src was the progress bar, and anyone could read it with one command. In a real repository, post it somewhere your team sees it.
Rules that keep it small
- One file per commit, renamed first.
git mvkeeps the history attached. - Leaves first. Dependencies before the files that use them.
- The build is green after every step. If a file is hard, leave it as JavaScript and come back. Nothing forces the order except the build.
- New files are TypeScript. From the day the pipeline works, nobody adds a
.jsfile. - No shortcuts with any. If you reach for it to get past an error, write down why, so you can come back.
If you skip this, you will pick the big-bang plan because it looks efficient, and the branch will be abandoned in week three. Which file in your repository imports nothing from the rest of it?
About
Convert a folder one file at a time while the build and the tests stay green, starting from the files that import nothing. After this, a migration is a series of small commits that anyone can review.
- Duration
- 10 min
- Last update
- 10/06/2026
Comments (0)
Sign in to commentNo comments yet.