TypeScript: Migrating a TypeScript JS Project to TS
Last updated: 2026-08-26
Migrating an existing JavaScript project to TypeScript is the most common and practical scenario—this lesson explains how to complete the migration safely and incrementally.
1. Overview of Migration Strategies
(1) Three Migration Strategies
| Strategy | Speed | Risk | Suitable Scenarios |
|---|---|---|---|
| One-time full migration | Fast | High | Small projects (< 20 files) |
| Incremental File-by-File Migration | Medium | Low | Medium-to-Large Projects (Recommended) |
| JSDoc Gradual Migration | Slow | Very Low | Large/Critical Projects |
(2) Overview of Migration Steps
1. Initialization tsconfig.json(Relaxed Mode)
2. Install @types packages
3. Open allowJs——TS and JS Coexistence
4. File by file .js → .ts(Starting with the leaf file)
5. Gradually Enable Strict Options
6. Finally Unlocked strict
2. Step 1: Initialize the Configuration
(1) Generate tsconfig.json
tsc --init
(2) Key Initial Configuration—Relaxed Mode
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"allowJs": true,
"checkJs": false,
"noImplicitAny": false,
"strict": false,
"outDir": "./dist",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
strict or noImplicitAny at the beginning of the migration—first make sure the project compiles, then gradually tighten the restrictions.
(3) Declaration of Installation Type
# Install the type declarations required by the project
npm install @types/node --save-dev
3. Step 2: use allowJs to enable coexistence of JS and TS
(1) Enable allowJs
{
"compilerOptions": {
"allowJs": true
}
}
allowJs allows the TypeScript compiler to accept .js files—projects can contain both JS and TS files without affecting each other.
(2) Import Compatibility
// main.ts —— Can be imported .js Documents
import { helper } from "./utils"; // utils.js Just being there is enough
// utils.js —— JS Documents,Untyped Annotations
export function helper(value) {
return value.toString();
}
(3) checkJs—Optional JavaScript type checking
{
"compilerOptions": {
"checkJs": true
}
}
When checkJs is enabled, TypeScript will also check for type errors in .js files (based on JSDoc comments and type inference). It is recommended to keep it disabled during the initial migration phase—enable it only after the JS files have been gradually converted to TS.
4. Step 3: Migrate File by File
(1) Migration Sequence
Start with the file that has the fewest dependencies—the "leaf" file (which contains utility functions, constants, etc., without importing files from other projects):
Recommended Migration Order:
1. Constants File(config.js → config.ts)
2. Utility Functions(utils.js → utils.ts)
3. Type Definitions(types.js → types.ts)
4. Data Model(models.js → models.ts)
5. Service Layer(services.js → services.ts)
6. Controller/Routing(controllers.js → controllers.ts)
7. Input File(index.js → index.ts)
(2) Steps for Migrating a Single File
// ── Before the Migration:utils.js ──
export function formatPrice(price, currency) {
return currency + price.toFixed(2);
}
export function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
// ── After the migration:utils.ts ──
export function formatPrice(price: number, currency: string = "¥"): string {
return currency + price.toFixed(2);
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
(3) Migration Techniques—Start with any and then Refine
// Step 1:Add a type annotation,Uncertain usage any
function process(data: any, options: any): any {
return data.filter(item => item.active);
}
// Step 2:Gradual Replacement any For a specific type
interface DataItem {
id: number;
name: string;
active: boolean;
}
interface Options {
limit?: number;
sort?: "asc" | "desc";
}
function process(data: DataItem[], options: Options = {}): DataItem[] {
let result = data.filter(item => item.active);
if (options.sort === "desc") result.reverse();
if (options.limit) result = result.slice(0, options.limit);
return result;
}
▶ Example: Migrating a Node.js route file
// ── Before the Migration:users.js ──
const http = require("http");
const server = http.createServer(async (req, res) => {
if (req.url === "/users" && req.method === "GET") {
const users = await findAllUsers();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(users));
}
});
server.listen(3000);
// ── After the migration:users.ts ──
import http from "http";
interface User {
id: number;
name: string;
email: string;
}
async function findAllUsers(): Promise<User[]> {
return [{ id: 1, name: "Charlie", email: "c@example.com" }];
}
const server = http.createServer(async (req, res) => {
if (req.url === "/users" && req.method === "GET") {
const users: User[] = await findAllUsers();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(users));
}
});
server.listen(3000);
5. JSDoc Type Annotations
If you don't want to change the file extension, you can use JSDoc to add a type to your JS file:
(1) Basic Type Annotations
// utils.js — Use JSDoc to add types
/**
* Pricing Format
* @param {number} price - Price
* @param {string} [currency="¥"] - Currency Symbol
* @returns {string} Formatted Price
*/
function formatPrice(price, currency = "¥") {
return currency + price.toFixed(2);
}
/**
* @typedef {Object} User
* @property {number} id
* @property {string} name
* @property {string} email
*/
/**
* Search for a User
* @param {number} id
* @returns {Promise<User>}
*/
async function findUser(id) {
// ...
}
module.exports = { formatPrice, findUser };
(2) Referencing JSDoc types in TS files
// main.ts
import { findUser } from "./utils"; // utils.js has JSDoc types
let user = await findUser(1); // ✅ Type inference is correct(From JSDoc)
(3) Common JSDoc Type Tags
| Tag | Purpose | Example |
|---|---|---|
@type |
Variable Type | @type {string} |
@param |
Parameter Type | @param {number} x |
@returns |
Return Type | @returns {string} |
@typedef |
Define Type | @typedef {Object} User |
@property |
Object Properties | @property {string} name |
@template |
Generic Parameters | @template T |
@callback |
Callback Type | @callback Handler |
6. Common Migration Pitfalls
(1) Pitfall 1: Implicit any Explosion
// A large number of implicit variables after migration any——Don't rush to start it noImplicitAny
function process(data) { // data Implicit any
return data.map(item => item.name); // item Me too any
}
Solution: First, get the project to compile successfully, then add type annotations one by one, and finally disable noImplicitAny.
(2) Pitfall 2: Conflict between module.exports and import
// JS For use with documents module.exports
module.exports = function() { /* ... */ };
// TS Import Requirements esModuleInterop
import fn from "./legacy"; // Required esModuleInterop: true
(3) Pitfall 3: Untyped Third-Party Libraries
import legacyModule from "legacy-module"; // ❌ Cannot find the declaration file
// Temporary solution——Create shim.d.ts
declare module "legacy-module" {
const lib: any;
export default lib;
}
(4) Pitfall 4: Loss of the this type
// JS in this Dynamic Binding
const obj = {
name: "Charlie",
greet() {
console.log(this.name); // ✅ JS in OK
}
};
// TS in this Needs annotation
const obj2 = {
name: "Charlie",
greet(this: { name: string }) {
console.log(this.name); // ✅ TS Needed in this Parameters
}
};
▶ Example: Adding Types to Plain JavaScript Objects
// ── Before: shapes.js ──
const shapes = [
{ type: "circle", radius: 5 },
{ type: "rectangle", width: 10, height: 20 },
{ type: "circle", radius: 3 }
];
function totalArea(shapes) {
return shapes.reduce((sum, s) => {
if (s.type === "circle") return sum + Math.PI * s.radius * s.radius;
return sum + s.width * s.height;
}, 0);
}
// ── After: shapes.ts ──
interface Circle { type: "circle"; radius: number; }
interface Rectangle { type: "rectangle"; width: number; height: number; }
type Shape = Circle | Rectangle;
const shapes: Shape[] = [
{ type: "circle", radius: 5 },
{ type: "rectangle", width: 10, height: 20 },
{ type: "circle", radius: 3 }
];
function totalArea(shapes: Shape[]): number {
return shapes.reduce((sum, s) => {
if (s.type === "circle") return sum + Math.PI * s.radius * s.radius;
return sum + s.width * s.height;
}, 0);
}
▶ Example: JSDoc to TypeScript Migration
// ── Before: math.js (JSDoc-typed) ──
/**
* @template T
* @param {T[]} arr
* @param {(item: T) => boolean} predicate
* @returns {T[]}
*/
function filter(arr, predicate) {
return arr.filter(predicate);
}
/**
* @typedef {Object} Point
* @property {number} x
* @property {number} y
*/
/**
* @param {Point} a
* @param {Point} b
* @returns {number}
*/
function distance(a, b) {
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
}
// ── After: math.ts (native TS types) ──
function filter<T>(arr: T[], predicate: (item: T) => boolean): T[] {
return arr.filter(predicate);
}
interface Point {
x: number;
y: number;
}
function distance(a: Point, b: Point): number {
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
}
❓ FAQ
tsc --noEmit to CI for type checking (without outputting files). During the early stages of migration, --noImplicitAny false is allowed to ensure that CI does not fail due to type issues. As the process gradually tightens, type checking in CI becomes increasingly strict.ts-ignore or ts-expect-error?@ts-expect-error first—it will throw an error if there is no type error on the next line (to prevent forgetting to remove the comment after the fix). @ts-ignore ignores errors unconditionally, which may mask errors that have already been fixed. Both are temporary workarounds; ultimately, you should fix the type issues.📖 Summary
- Migration Strategy: Incremental, file-by-file migration (recommended), starting with leaf files and ending with entry files
- Initial configuration in lenient mode: allowJs enabled to allow coexistence, noImplicitAny disabled, strict disabled
- Single-file migration: .js → .ts—first add type annotations (using
anyas a fallback), then refine the types - JSDoc type annotations are a compromise that doesn't change file extensions—ideal for gradual migration in large projects
- Common pitfalls: Implicit
anyexplosion,module.exportsconflicts, untyped third-party libraries, and missingthistypes - Gradually enable strict options—each time an option is enabled, all errors are fixed
📝 Exercises
- Basic Exercise (Difficulty ⭐): Migrate a simple JavaScript utility file (3–5 functions) to TypeScript—add type annotations for parameters and return values to each function, and ensure it compiles successfully.
- Advanced Exercise (Difficulty: ⭐⭐): Configure
tsconfig.jsonto support mixed JS/TS projects—enableallowJs, use JSDoc to add types to a JS file, then import and use it in a TS file. - Challenge (Difficulty: ⭐⭐⭐): Simulate migrating a Node.js HTTP server project—configure
tsconfig, add types to theIncomingMessageandServerResponseobjects, handle type safety for request body parsing (define the body interface), and handle URL parameter extraction.