Skip to Content
TypeScript Advanced: Types That Hold Up in Production
Advanced
0 likes
Courses
TypeScript Advanced: Types That Hold Up in Production

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.

Course Home

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 mv keeps 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 .js file.
  • 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
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.