Chapter 23 of 24 · TypeScript for Beginners — Complete Free Tutorial

Migrating JavaScript to TypeScript

Nobody starts a greenfield project here: the usual starting point is a JavaScript application that already works and cannot be rewritten. This twenty-third chapter of the 24-chapter TypeScript for Beginners series is the incremental adoption playbook — how to let .js and .ts coexist, which files to convert first, what to do about untyped dependencies, when a suppression comment is acceptable, and how to keep the whole thing honest in CI.

Adopt TypeScript without rewriting

Two flags make the compiler useful on a mixed codebase immediately: allowJs brings JavaScript files into the program so imports between languages resolve, and noEmit stops tsc writing output your bundler already produces.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowJs": true,
    "checkJs": false,
    "strict": true,
    "noEmit": true
  },
  "include": ["src"]
}

Checking JavaScript is then a decision per file rather than per project. "checkJs": true checks every .js file; the lighter alternative is a single comment at the top of a file you are willing to fix:

// @ts-check
function add(a, b) {
  return a + b;
}

export { add };

Expected output: that file reports error TS7006: Parameter 'a' implicitly has an 'any' type. while an untouched plain.js in the same folder stays silent. You get to choose the size of each step — one function, one module, one sprint.

Rename one file at a time

Conversion is mechanical, and the order decides whether it is painless. Start with leaves — modules that nothing else imports deep inside — and work inward toward the entry point:

  1. Pick a file with few imports and no JSX.
  2. Rename it: utils.js → utils.ts. JSX files go to .tsx instead; anything else stays .ts.
  3. Run npx tsc --noEmit and read the new errors — they are the honest list of what the file never said out loud.
  4. Fix implicit any first: annotate every parameter, then every return type you had to guess at. This is TS7006, and it is usually a five-minute job per function.
  5. Annotate the public API — exported functions, exported types, the shape of objects crossing the module boundary. Internal locals can keep inferring.
  6. Fix the errors that appear in importing files, which are the real deliverable: callers now get checked arguments for free.

Update imports that named the old extension (a ./utils.js specifier still resolves to utils.ts under moduleResolution: "bundler", but explicit extensionless imports read cleaner), then commit. One file per commit keeps the review small and makes a regression a single git revert.

Third-party code without types

Untyped dependencies are the other source of red. Exhaust the easy options first, in this order:

SituationFix
The package ships its own typesNothing to do; check node_modules/legacy-ui/*.d.ts
DefinitelyTyped has themnpm install --save-dev @types/legacy-ui
No types anywhere, small APIWrite an ambient declaration in src/types/legacy-ui.d.ts
No types, large or shifting APIWrap it in one typed module of your own and import the wrapper everywhere
// src/types/legacy-ui.d.ts
declare module 'legacy-ui' {
  export function openDialog(title: string): void;
  export function closeAll(): void;
}

Expected output: openDialog('Save') compiles and openDialog(42) reports error TS2345. The declaration file is a promise about someone else's API, so keep it thin and re-check it when the dependency upgrades — a wrong .d.ts is worse than none, because it hides the very calls that break.

@ts-expect-error vs @ts-ignore

Suppression comments are the migration's pressure valve, and the two spellings behave very differently:

DirectiveBehaviourWhen it is acceptable
// @ts-expect-errorSilences the next line's error, and fails with error TS2578: Unused '@ts-expect-error' directive. once that line compiles cleanlyThe default: it documents a known-bad line and retires itself automatically
// @ts-ignoreSilences the next line unconditionally, including errors introduced laterTemporarily, on a dependency's broken declaration, with a comment linking the issue
// @ts-expect-error - legacy-ui v2 types still declare the old signature
legacyUI.openDialog({ title: 'Save' });

Both belong on code you do not own. On your own code, a suppression is a bug report you have chosen not to file: fix the type, narrow the value, or return Result instead — the tools from Error Handling and Result Types remove most of the temptation.

Gate the build in CI

An incremental migration only stays incremental if nothing regresses. Add one script and one CI step:

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "build": "vite build"
  }
}
npm run typecheck

Expected output: on a healthy branch, nothing at all — tsc is silent on success and exits 0. When a pull request breaks it, the failure names the file:

src/api/user.ts(14,22): error TS18046: 'err' is of type 'unknown'.
src/api/user.ts(31,9): error TS2322: Type 'string | undefined' is not assignable to type 'string'.

error TS6001: Exit code was 2.

Run it on every pull request alongside the test suite, and make the branch required. From that moment, no new any, no new unchecked null and no newly untyped file can merge — the migration converges instead of leaking.

Before and after

The end state is not a different application; it is the same application with its unstated assumptions written down:

Before (JavaScript)After (TypeScript)
function save(user) { ... }function save(user: User): Promise<void> { ... }
const data = await res.json();const data: Course[] = await res.json();
App.jsx with untyped propsApp.tsx with interface AppProps
require('./db') / module.exportsimport db from './db' / export
Types in comments, if anywhereTypes enforced on every save and in every editor
npm test onlynpm run typecheck plus npm test in CI

Common mistakes

  • Converting the entry point first — it imports everything, so one rename produces hundreds of errors; start with leaf modules and work inward.
  • Reaching for any to finish a file — the errors disappear and the migration does not; prefer unknown plus narrowing, or a real interface.
  • Deleting allowJs too early — unconverted files must stay in the program or their imports silently go unchecked.
  • Using @ts-ignore where @ts-expect-error fits — you lose the automatic retirement that proves the suppression is still needed.
  • Skipping CI — without tsc --noEmit on every pull request, the last 20% of a migration never happens.

Practice checklist

  1. Add allowJs and noEmit to a JavaScript project and confirm npx tsc --noEmit runs clean.
  2. Add // @ts-check to one script and fix the TS7006 errors it surfaces.
  3. Rename one leaf module to .ts, annotate its exports, and note which importing file now reports new errors.
  4. Write an ambient declare module for an untyped package and prove a wrong argument type fails.
  5. Put npm run typecheck into a CI workflow and open a pull request that deliberately fails it.

What's next?

The migration is running and gated. The final chapter collects the habits that keep a typed codebase healthy — TypeScript Best Practices and Where to Go Next.

Quiz: Migrating JavaScript to TypeScript

5 questions · 70% to pass

Frequently Asked Questions

TypeScript is a strict superset of JavaScript that adds a static type system. You annotate the promises your data makes, the compiler checks every one before the code runs, then erases the types and emits ordinary JavaScript. You catch wrong arguments, typoed properties and forgotten null checks while you are still typing, and refactoring plus editor autocomplete both improve sharply.

You should. This series assumes you can already read and write JavaScript: functions, arrays and objects, closures, modules and async/await. TypeScript audits that knowledge rather than replacing it, and types cannot rescue code you do not understand. If those fundamentals feel shaky, work through the JavaScript for Beginners series on this site first, and this course will go much faster.

No. TypeScript is compiled, not interpreted: tsc parses your file, type-checks it, then erases every annotation and writes ordinary JavaScript. The browser and Node.js only ever run that emitted .js, so runtime behaviour, performance and debugging are unchanged. It also never validates API data at runtime, so the series teaches you to distrust every boundary that leaves the compiler.

If you already know JavaScript, TypeScript is a modest step rather than a new language: the syntax is the same plus a colon. Most beginners read their first red squiggle within days and feel comfortable in two to four weeks of regular practice. The curve steepens later around generics and advanced types, which is where this 24-chapter series deliberately spends its second half.

Both name the same object shapes, so either works for a simple contract. The differences decide the rest: only interfaces merge when declared twice, while only type aliases can express unions, primitives, tuples and mapped types. The series default is interface for props and exported shapes other files consume, and type for everything else. Pick one style per project and stay consistent.

strict: true is a bundle of safety switches, not a single behaviour. It turns on noImplicitAny, so every parameter must be inferrable, strictNullChecks, so null and undefined are their own types you must narrow, strictFunctionTypes, noImplicitThis, strictPropertyInitialization, and useUnknownInCatchVariables, which types catch values as unknown. Every example in this series compiles with it on.

any accepts anything and switches the compiler off, so one any spreads through inference into everything derived from it and you are back to dynamic typing with a .ts extension. unknown accepts anything too, but forbids property access until you narrow it with a typeof or instanceof check. The series pushes you toward unknown because it keeps the check you installed TypeScript to get.

Yes to both. Vite understands TypeScript natively: scaffold the vanilla-ts or react-ts template and a tsconfig.json is waiting for you, with moduleResolution set for the bundler. JSX belongs in .tsx files, props get an interface, and hooks infer or accept explicit type arguments. The series covers typed React in depth, from props, state and events to refs, context and fetch effects.

Enums still compile and work fine in TypeScript 5.x, but they are no longer the default recommendation. A union of string literals such as "light" | "dark" needs no runtime object, matches JSON directly and plays well with single-file transpilers like Vite's esbuild. Keep enum for existing codebases that already use it, avoid const enum, and prefer literal unions in new code.

Adopt it incrementally instead of rewriting. Install typescript, add a tsconfig.json with allowJs and noEmit so .js and .ts coexist while your bundler keeps building, then check individual files with a // @ts-check comment. Rename leaf modules to .ts one at a time, fix implicit any errors first, and gate every pull request with tsc --noEmit so untyped code cannot sneak back in.