How to Add TypeScript to an Existing Node.js Project
The fastest path is to install TypeScript as a dev dependency, generate a tsconfig.json with allowJs and checkJs: false, then rename files from .js to .ts one module at a time. Your existing JavaScript keeps running while you migrate incrementally, which avoids a risky big-bang rewrite.
Install TypeScript and the Node Types
Install the compiler, Node type definitions, and ts-node so you can run TypeScript directly during development.
npm install --save-dev typescript @types/node ts-node
# Add type packages for libraries you already use.
npm install --save-dev @types/express @types/lodash
Create a Migration-Friendly tsconfig.json
The key flags for an incremental migration are allowJs: true, so .js and .ts can coexist, checkJs: false, so the compiler skips type-checking existing JS, and strict: false at the start. You tighten these over time.
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"allowJs": true,
"checkJs": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"strict": false,
"noImplicitAny": false,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.js"]
}
Update package.json Scripts
Keep your existing start command functional while you add a build step and a dev script that runs TypeScript sources directly.
{
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts",
"watch": "tsc --watch",
"typecheck": "tsc --noEmit"
}
}
Convert Files One at a Time
Rename a leaf module (something with few dependents) from .js to .ts first, then work upward toward your entry point. The compiler flags obvious issues. For imports that do not yet have types, declare them as any in an ambient declaration file rather than fighting the compiler.
// src/types/shims.d.ts
// Silence missing types for untyped dependencies temporarily.
declare module 'legacy-internal-lib';
declare module 'some-untyped-package' {
const value: any;
export default value;
}
Convert a Module in Practice
Start by adding explicit types to function signatures and exported values. Leave local variables untyped and let inference do the work. This is a realistic first conversion that annotates only what crosses module boundaries.
// src/services/userService.ts
import { db } from '../db';
export interface User {
id: string;
email: string;
createdAt: Date;
}
export async function findUserById(id: string): Promise<User | null> {
const row = await db.query('SELECT * FROM users WHERE id = $1', [id]);
return row ?? null;
}
Tighten the Config as You Go
After you convert most files, flip strict: true and address errors module by module. A useful intermediate step is enabling noImplicitAny first, then strictNullChecks, and then the rest of strict mode. Do not enable checkJs on remaining .js files unless you want to annotate them with JSDoc, which is a reasonable path for code you do not plan to convert.
Gotchas That Trip People Up
The biggest one is esModuleInterop. Without it, import express from 'express' fails because Express uses CommonJS exports. Always set it to true for Node projects. Second, if your build produces files in dist/ but your package.json still has "main": "src/index.js", published or linked packages break. Update main to dist/index.js and add "types": "dist/index.d.ts" with "declaration": true in tsconfig.json if you publish a library.
Third, watch out for ts-node in production. It works for development but runs slower and heavier than compiled output. Always run tsc in CI and deploy the dist/ output. Finally, if you use ESM ("type": "module" in package.json), set "module": "NodeNext" and "moduleResolution": "NodeNext" instead of commonjs, and remember that relative imports must include the .js extension even when the source is .ts.
Alternative: JSDoc Without Renaming
If you cannot rename files (for example, a large monorepo with fragile build tooling), enable allowJs and checkJs and add JSDoc type annotations to your .js files. You get most of TypeScript's type checking without changing file extensions, at the cost of more verbose syntax. This is how VS Code and webpack added types to their codebases for years.
// @ts-check
/**
* @param {string} id
* @returns {Promise<{ id: string, email: string } | null>}
*/
async function findUserById(id) {
const row = await db.query('SELECT * FROM users WHERE id = $1', [id]);
return row ?? null;
}
module.exports = { findUserById };