Migrating a JavaScript project to TypeScript should not require a feature freeze. In most production teams, stopping delivery for a large rewrite is not realistic, and it often creates more risk than it removes. The safer approach is to treat TypeScript adoption as an incremental engineering program: preserve runtime behavior, add type coverage where it reduces real defects, and tighten compiler rules only where the team can absorb the cost.
The central idea is simple: do not migrate files just to change extensions. Migrate boundaries, contracts, and failure-prone areas first. allowJs, checkJs, module-level strict configs, typed critical zones, and CI checks let a team improve type safety without turning the whole codebase into a blocking refactor.
What teams usually get wrong
The most common mistake is treating TypeScript migration as a binary switch: JavaScript on Monday, TypeScript on Friday. That model fails because real projects contain legacy modules, dynamic data, third-party integrations, implicit contracts, tests with mocks, and runtime assumptions that were never written down.
Another mistake is renaming too many files too early. A .ts extension does not automatically make code safer. If the file still uses any, passes unvalidated API responses around, and hides domain rules inside loosely shaped objects, the migration has mostly changed syntax.
A useful migration plan should protect delivery by separating four concerns:
Can JavaScript and TypeScript coexist?
Which JavaScript files should be type-checked before conversion?
Which modules should become strict first?
Which parts of the system deserve explicit types before everything else?
That order matters. It turns migration from a rewrite into a sequence of controlled compiler gates.
Phase 1: Make JavaScript and TypeScript coexist
The first milestone is not strict typing. It is compiler integration without disrupting the existing build. allowJs lets TypeScript include JavaScript files in the project, while noEmit is often useful when another tool already handles bundling or transpilation.
A conservative starting config can look like this:
{
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"noEmit": true,
"incremental": true,
"moduleResolution": "bundler",
"target": "ES2022",
"module": "ESNext",
"strict": false,
"skipLibCheck": true
},
"include": ["src"]
}This does not make the project “typed” in a meaningful sense yet. It gives the team a compiler view of the codebase and makes it possible to introduce .ts and .tsx files without isolating them from the rest of the application.
At this point, avoid aggressive cleanup. The goal is to keep the application building, keep tests running, and make TypeScript part of the normal toolchain. Any migration plan that breaks local development on day one will lose trust quickly.
Phase 2: Use checkJs where conversion is not worth it yet
Not every JavaScript file needs to become TypeScript immediately. Some files are stable, small, or scheduled for replacement. Others are risky to rename because they interact with build tooling, test fixtures, or older module systems.
For those files, checkJs can provide useful compiler feedback without changing file extensions. It can be enabled globally later, but a safer path is to start locally with // @ts-check and JSDoc annotations.
// @ts-check
/**
* @typedef {Object} Price
* @property {number} amount
* @property {"USD" | "EUR" | "GBP"} currency
*/
/**
* @param {Price} price
* @param {number} quantity
* @returns {number}
*/
export function calculateSubtotal(price, quantity) {
return price.amount * quantity;
}This is not a substitute for TypeScript in complex modules, but it is useful for leaf utilities, shared helpers, and old JavaScript files where the team wants type feedback before deciding whether conversion is worth the churn.
The practical rule is: use checkJs to reduce blind spots, not to avoid TypeScript indefinitely.
Migration controls compared
A good migration uses several controls at once. Each one has a different operational role.
Control | Applies to | Runtime impact | Migration cost | Useful for | Main constraint |
|---|---|---|---|---|---|
allowJs | JS and TS coexistence | None | Low | Starting the migration without rewriting files | Does not type-check JS by itself |
checkJs | JavaScript files | None | Low to Medium | Finding errors in existing JS before conversion | JSDoc can become noisy in complex modules |
File rename to .ts | Individual files | None if build output is equivalent | Medium | Modules with stable imports and clear contracts | May expose many implicit assumptions |
Module-level strict config | Selected folders | None | Medium | Gradual hardening by domain or package | Requires disciplined CI setup |
Typed boundary layer | API, queues, events, config | None to Low, depending on validation | Medium | Reducing production defects from malformed data | Needs ownership of data contracts |
The table highlights a key point: TypeScript migration is not one action. It is a set of pressure valves. Use the light controls early, then apply stricter controls where the return is highest.
Phase 3: Enable strict mode by module, not by wishful thinking
Turning on strict for a large existing project can produce a wall of compiler errors. Many are useful. Many are also impossible to fix without interrupting roadmap work.
A more practical model is to keep the base config permissive and introduce strict configs for selected modules. For example, a checkout flow, authentication module, billing adapter, or shared SDK can be checked with stricter rules before the rest of the codebase is ready.
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strict": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true
},
"include": ["src/checkout/**/*"]
}This lets the team create a measurable migration path:
Pick one module with clear ownership.
Add a strict TypeScript config for that module.
Fix compiler errors inside the boundary.
Add the strict config to CI.
Repeat with the next module.
The best migration signal is not how many files have .ts extensions. It is how many important modules are protected by compiler rules the team cannot silently bypass.
Strict mode works best when it is attached to ownership. A team responsible for payments can harden the payments module. A platform team can harden shared libraries. A frontend team can harden state management and API client code. This reduces coordination cost and avoids turning migration into a single cross-company bottleneck.
Phase 4: Type the critical zones first
In most applications, defects are not evenly distributed. TypeScript brings the most value where data crosses boundaries or where incorrect assumptions are expensive.
Prioritize these areas:
API request and response objects
Authentication and authorization logic
Payment, billing, and subscription flows
Queue payloads and event messages
Configuration and environment variables
Shared domain models used across many modules
State transitions in complex UI flows
Public package or SDK interfaces
For example, an API client that returns untyped data spreads uncertainty across the application:
// Weak migration: the caller still has to guess the shape.
export async function getCurrentUser(): Promise<any> {
const response = await fetch("/api/me");
return response.json();
}A better version makes the boundary explicit:
type UserRole = "admin" | "editor" | "viewer";
interface CurrentUser {
id: string;
email: string;
role: UserRole;
}
export async function getCurrentUser(): Promise<CurrentUser> {
const response = await fetch("/api/me");
if (!response.ok) {
throw new Error("Failed to load current user");
}
const data = await response.json();
return {
id: String(data.id),
email: String(data.email),
role: data.role
};
}This example is intentionally small. In production, runtime validation may also be needed, especially when data comes from external systems. TypeScript describes expected shapes at compile time. It does not prove that a JSON payload is valid at runtime.
That distinction is important. TypeScript reduces many classes of mistakes inside the codebase, but it does not remove the need for validation at trust boundaries.
Phase 5: Make CI enforce progress, not perfection
A migration without CI usually drifts. Developers rename a few files, suppress errors, and move on. The project looks more typed, but the compiler does not become a reliable guardrail.
CI should enforce two things:
The current baseline must not get worse.
Strict modules must remain strict.
A minimal CI setup can run the general compiler check and one or more strict module checks:
name: type-check
on:
pull_request:
push:
branches: [main]
jobs:
types:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run typecheck
- run: npm run typecheck:checkoutPackage scripts can keep those checks explicit:
{
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"typecheck:checkout": "tsc -p tsconfig.checkout.strict.json"
}
}Do not make CI depend on a vague target like “finish TypeScript migration.” Make it enforce concrete rules:
New TypeScript files must pass the compiler.
Selected modules must pass strict checks.
New public APIs must have typed inputs and outputs.
Suppressions such as
@ts-ignoremust be reviewed.Broad
anyusage should be limited in critical zones.
This creates a migration that gets stronger over time without blocking every unrelated feature.
Managing any, unknown, and suppressions
any is not always avoidable during migration, but it must be treated as technical debt with location and reason. The dangerous pattern is not a single any. It is an any that crosses module boundaries and becomes part of the application contract.
Prefer unknown when the value is genuinely not trusted yet:
function readFeatureFlag(value: unknown): boolean {
if (typeof value === "boolean") {
return value;
}
if (value === "true") {
return true;
}
return false;
}This forces narrowing before use. It is more work than any, but it documents uncertainty and prevents accidental property access.
For suppressions, use a narrow policy. @ts-expect-error is usually safer than @ts-ignore because it should become visible when the error no longer exists. Even then, suppressions should explain the reason, especially near public interfaces or business-critical code.
A practical migration sequence
A delivery-safe TypeScript migration can follow this order:
Add TypeScript tooling with
allowJs.Keep the existing build path stable.
Add
checkJsto selected JavaScript files with clear value.Convert leaf utilities and shared types first.
Type API clients, event payloads, config, and domain boundaries.
Create strict configs for one owned module at a time.
Add each strict config to CI.
Track suppressions and reduce them during normal feature work.
Avoid large renaming waves unless the module is already understood.
Make typed contracts part of new feature acceptance criteria.
The sequence is intentionally incremental. It lets teams ship product work while moving risk out of the codebase in visible steps.
For engineers who work with TypeScript as a core production skill, the Senior TypeScript Developer certification is the most relevant DevCerts track to review after building this kind of migration discipline.
Conclusion
A successful JavaScript to TypeScript migration is not measured by how quickly the repository changes file extensions. It is measured by whether important contracts become explicit, whether risky modules get stricter, and whether CI prevents regression without freezing delivery.
Start with coexistence through allowJs. Use checkJs where it gives fast feedback. Apply strict by module, tied to ownership. Type the zones where bad assumptions cause production defects. Then let CI preserve every step forward.
That approach is slower than a rewrite on paper, but it is usually safer in real teams because it respects the actual constraints of production software: shipping, maintenance, testing, ownership, and risk.