When our team decided to migrate to TypeScript, I thought it'd take a week. It took three months. Here's what actually happened.
We had a Node.js API — about 40,000 lines of JavaScript, a couple hundred files, a decent test suite, and a CI pipeline that ran on every PR. The codebase worked. Bugs were manageable. But we kept running into the same class of problems: someone would rename a field in one place and miss three others, or a function would silently accept the wrong argument shape and blow up in production at 2 AM. We figured TypeScript would fix most of that. We were right, eventually. But the road there was bumpier than expected.
Week One: Setup and False Confidence
The first few days felt great. We installed TypeScript and ts-node, generated a tsconfig.json, and had our first .ts file compiling within an hour.
npm install --save-dev typescript ts-node
npx tsc --initThe default tsconfig.json that tsc --init generates has dozens of options, most commented out. We trimmed it down to a minimal starting point with strict turned off — the plan was to tighten things later:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"allowJs": true,
"checkJs": false,
"declaration": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"strict": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}The setting that makes the whole migration possible is allowJs. With it set to true, the TypeScript compiler will accept .js files alongside .ts files. Your existing JavaScript passes through untouched, and any file you rename to .ts gets type-checked. Without this, you'd have to convert everything in one shot — which, trust me, is not how you want to spend your month.
We converted five utility files on day one. They compiled immediately. Tests passed. We felt unstoppable.
Weeks Two and Three: The Grind
The honeymoon ended fast. The utility files had been easy because they had no internal dependencies — just pure functions that take a value and return a value. Once we moved on to files that import from other files, things got messier.
Our workflow became a loop:
- Pick a
.jsfile with minimal imports from other project files. - Rename it from
.jsto.ts. - Fix whatever the compiler complained about.
- Run the test suite.
- Commit and pick the next file.
The most common errors were implicit any types on function parameters and missing return type annotations. With strict off, most of these were warnings we could address quickly. Here's a typical before-and-after:
// Before migration (utils.js)
function formatCurrency(amount, currency) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount);
}
module.exports = { formatCurrency };
// After migration (utils.ts)
export function formatCurrency(amount: number, currency: string): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount);
}We also switched from module.exports to ES module export syntax while we were at it. This is optional — TypeScript handles both thanks to esModuleInterop — but ES modules gave us better tooling support and more consistent behavior across the codebase.
By week three, we had converted maybe 60 files out of 200. The pace was slowing down because we were hitting the more connected parts of the codebase — route handlers, middleware, database layers — where a single file imports from ten others. Some afternoons, we seriously considered giving up and reverting everything.
The @types Scavenger Hunt
Around week two, we started seeing a lot of this: Could not find a declaration file for module 'express'. Most npm packages don't ship their own type definitions, so you need to install them separately from DefinitelyTyped:
npm install --save-dev @types/node @types/express @types/lodash @types/cors @types/morganFor popular packages, this worked fine. But we had a handful of smaller dependencies with no @types package at all. For those, we had two options.
The first was writing a declaration file ourselves. We created a typings.d.ts in our source directory:
// typings.d.ts
declare module 'some-untyped-library' {
export function doSomething(input: string): number;
export interface Config {
verbose: boolean;
retries: number;
}
}The second, faster option was to just declare the whole module as any:
// typings.d.ts
declare module 'some-untyped-library';This kills the error but gives you zero type safety for that module. We used it as a stopgap and left // TODO: write proper types comments everywhere. Six months later, we've fixed about half of them. The rest still haunt us.
Month Two: The Strict Mode Gauntlet
Once all files were converted to .ts, we turned our attention to strict mode. The "strict": true flag is actually a shorthand that enables a bunch of individual checks:
- noImplicitAny — errors when a type defaults to
any - strictNullChecks —
nullandundefinedbecome distinct types - strictFunctionTypes — stricter checking on function parameter types
- strictBindCallApply — checks
bind,call, andapplyarguments - strictPropertyInitialization — class properties must be initialized in the constructor
- noImplicitThis — errors when
thisis implicitlyany - alwaysStrict — emits
"use strict"in every file - useUnknownInCatchVariables — catch variables typed as
unknowninstead ofany
We made the mistake of turning on strict: true all at once to "see how bad it was." The answer: 1,400 errors. We immediately turned it back off and adopted a phased approach instead:
// Phase 1: Start with these
"noImplicitAny": true,
"alwaysStrict": true,
// Phase 2: After fixing phase 1 errors
"strictNullChecks": true,
"useUnknownInCatchVariables": true,
// Phase 3: Tighten further
"strictFunctionTypes": true,
"strictBindCallApply": true,
// Phase 4: Final strict options
"strictPropertyInitialization": true,
"noImplicitThis": truePhase 1 was manageable — mostly adding type annotations to function parameters. Phase 2, strictNullChecks, was the beast. Every function that could return null needed its callers to handle that case. Our database layer alone produced 300+ errors because every query result was potentially undefined. But here's the thing: at least a dozen of those errors pointed to genuine bugs — places where we were accessing properties on a value that could be null, and had just been getting lucky in production.
After all individual flags were clean, we replaced them with a single "strict": true and deleted the individual entries. That way, any new strict checks added in future TypeScript versions get picked up automatically.
The Mistakes That Cost Us Days
Some of these we learned the hard way. Maybe you won't have to.
Forgetting to update import paths. When you rename utils.js to utils.ts, any file that imports it with an explicit .js extension will break. TypeScript resolves imports without extensions by default, so the fix is to drop the extension entirely:
// Bad - will break when file is renamed
const { formatCurrency } = require('./utils.js');
// Good - works with both .js and .ts
import { formatCurrency } from './utils';Reaching for any whenever a type got annoying. We did this more than I'd like to admit. Every any is a hole in your type coverage, and they spread — once one function returns any, every function that calls it also becomes untyped. Use unknown instead when you genuinely don't know the type, and narrow it with a type guard. If you absolutely must use any, leave a // TODO: fix any comment.
Forgetting about the test suite. Our Jest setup didn't know how to handle .ts files. We had to install ts-jest and update the config:
npm install --save-dev ts-jest
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleFileExtensions: ['ts', 'js', 'json'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
};Circular dependencies surfacing. TypeScript caught circular imports that had been silently working in JavaScript. Two of our module graphs had cycles we didn't know about. Fixing them required extracting shared interfaces into separate files.
Trusting skipLibCheck too long. We'd set skipLibCheck: true to quiet errors from node_modules declaration files. That's fine during migration, but it also hides errors in your own .d.ts files. Once we turned it off, we found three bugs in our custom declaration files.
Setting Up the Build Pipeline
With the migration mostly done, we needed a build setup that would actually work in production. Here's what we landed on for package.json scripts:
{
"scripts": {
"build": "tsc",
"build:watch": "tsc --watch",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts",
"lint": "eslint 'src/**/*.{ts,js}'",
"typecheck": "tsc --noEmit"
}
}build compiles everything into the dist folder. dev uses ts-node to run TypeScript directly — no compile step, which is nice during development. typecheck runs the compiler without emitting files, purely for CI.
After a few weeks, tsc started feeling slow on our 200-file project (about 8 seconds per build). We switched to esbuild for the actual transpilation and kept tsc --noEmit just for type-checking:
npm install --save-dev esbuild
// build.mjs
import { build } from 'esbuild';
await build({
entryPoints: ['src/index.ts'],
bundle: true,
platform: 'node',
target: 'node20',
outdir: 'dist',
format: 'cjs',
sourcemap: true,
});Builds dropped to under a second. We run both esbuild (for the actual artifact) and tsc --noEmit (for type safety) in CI. It's worked well.
We also added a clean step so stale .js files in dist don't stick around when you rename or delete a source file:
{
"scripts": {
"clean": "rm -rf dist",
"prebuild": "npm run clean"
}
}And we wired TypeScript checking into pre-commit hooks with husky and lint-staged, so type errors can't sneak into the repo.
Things I'd Do Differently
If I had to do this migration again from scratch, here's what I'd change:
- Start with
noImplicitAnyenabled from day one instead of leavingstrictentirely off. It catches real problems and the errors are easy to fix one file at a time. - Set up the build pipeline (esbuild +
tsc --noEmit) before converting any files, not after. We wasted time debugging build issues mid-migration. - Convert the test suite configuration on day one. We went two weeks with broken tests because we kept putting off the Jest config.
- Track every
anyand every baredeclare modulein a spreadsheet or issue tracker. We used TODO comments, and half of them got lost. - Do the database layer first, not last. It touches everything and strictNullChecks hits it hardest. Ripping that bandage off early would have saved us weeks of cascading fixes later.
- Communicate a realistic timeline to the rest of the team. "A couple of weeks" turned into three months, and that caused friction with feature work. If we'd said "one quarter" up front, nobody would have been surprised.
- Run
tsc --noEmitin CI from the very first commit that addstsconfig.json. We didn't, and type errors piled up in unconverted files for weeks before anyone noticed.
Three months in, would I do it again? Absolutely. The migration was painful, but the codebase that came out the other side is fundamentally easier to work with. Refactoring went from a high-anxiety activity to something I barely think about — rename a field, and the compiler tells you every file that needs updating. New team members ramp up faster because the types serve as documentation that can't go stale.
The bugs we caught during the strictNullChecks phase alone justified the effort. One of them was a query result that could return undefined when the database row didn't exist, and we were accessing .email on it without checking. It had been silently throwing in production for weeks — a null reference error that got swallowed by our error handler and logged as a generic 500. TypeScript flagged it in about two seconds flat.
If your team is on the fence, my advice is: don't try to sell it as a one-week project. It isn't. Budget a quarter, start with allowJs and loose settings, convert the leaf nodes of your dependency graph first, and tighten the screws gradually. The destination is worth the trip.
Comments (0)
No comments yet. Be the first to share your thoughts!