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:
- Pick a file with few imports and no JSX.
- Rename it:
utils.js→utils.ts. JSX files go to.tsxinstead; anything else stays.ts. - Run
npx tsc --noEmitand read the new errors — they are the honest list of what the file never said out loud. - Fix implicit
anyfirst: 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. - Annotate the public API — exported functions, exported types, the shape of objects crossing the module boundary. Internal locals can keep inferring.
- 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:
| Situation | Fix |
|---|---|
| The package ships its own types | Nothing to do; check node_modules/legacy-ui/*.d.ts |
| DefinitelyTyped has them | npm install --save-dev @types/legacy-ui |
| No types anywhere, small API | Write an ambient declaration in src/types/legacy-ui.d.ts |
| No types, large or shifting API | Wrap 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:
| Directive | Behaviour | When it is acceptable |
|---|---|---|
// @ts-expect-error | Silences the next line's error, and fails with error TS2578: Unused '@ts-expect-error' directive. once that line compiles cleanly | The default: it documents a known-bad line and retires itself automatically |
// @ts-ignore | Silences the next line unconditionally, including errors introduced later | Temporarily, 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 props | App.tsx with interface AppProps |
require('./db') / module.exports | import db from './db' / export |
| Types in comments, if anywhere | Types enforced on every save and in every editor |
npm test only | npm 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
anyto finish a file — the errors disappear and the migration does not; preferunknownplus narrowing, or a real interface. - Deleting
allowJstoo early — unconverted files must stay in the program or their imports silently go unchecked. - Using
@ts-ignorewhere@ts-expect-errorfits — you lose the automatic retirement that proves the suppression is still needed. - Skipping CI — without
tsc --noEmiton every pull request, the last 20% of a migration never happens.
Practice checklist
- Add
allowJsandnoEmitto a JavaScript project and confirmnpx tsc --noEmitruns clean. - Add
// @ts-checkto one script and fix the TS7006 errors it surfaces. - Rename one leaf module to
.ts, annotate its exports, and note which importing file now reports new errors. - Write an ambient
declare modulefor an untyped package and prove a wrong argument type fails. - Put
npm run typecheckinto 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.
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.