Adopting TypeScript Gradually
Advanced · 17 min read · ▶ live playground · ✦ checkpoint
You do not have to convert a whole JavaScript codebase in one dramatic weekend. TypeScript adoption works best as a ratchet: add a sensible config, check the code you can check today, migrate one file at a time, and make the project a little harder to break each week.
This lesson is the tool-shaped close to the TypeScript taster. You will see the project pieces, not run installs here: tsconfig, file-by-file migration, @ts-check plus JSDoc for the in-between, Vite scripts, and CI type checks.
Start with a strict config
A tsconfig file tells TypeScript which files belong to a project and how strict the checker should be. For new code, start strict. Turning strictness on later is harder because the project has already trained itself to rely on loose behavior.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}Read the important parts:
strict: trueturns on the checks that make TypeScript worth using: null checks, function parameter checks, safer inference, and more.noEmit: truemeans TypeScript checks the project but does not write JavaScript output. A build tool can handle output.include: ["src"]keeps the checker focused on your source files instead of every folder in the project.
Config details vary by framework and template, so copy the local project standard when one exists. The principle is stable: make the checker explicit, keep it strict, and run it as a normal project command.
Migrate file by file
The safest migration is boring. Pick one small file, rename it from .js to .ts, fix the errors, commit, and repeat.
src/
format-cents.js -> format-cents.ts
cart-total.js -> cart-total.ts
checkout-view.js -> checkout-view.tsSmall utility files are good first candidates because they have fewer browser or framework edges. A migrated file can look almost like JavaScript with a few boundary annotations:
export function formatCents(cents: number): string {
const dollars = cents / 100;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(dollars);
}Do not try to "type everything" in one pass. First, make the file check. Then improve names, shared types, and boundaries when the next change naturally touches them.
A useful migration rhythm:
- Rename one file.
- Add annotations at the file boundary.
- Let inference handle local values.
- Replace obvious
anywith real types orunknown. - Run the type check.
- Keep the change small enough to review.
That rhythm turns TypeScript into a steady ratchet instead of a rewrite project.
Use @ts-check before renaming
Sometimes a file is not ready to become .ts: maybe it is still plain JavaScript, shared with older tooling, or too risky to rename during the current change. You can still ask TypeScript to check it.
Put // @ts-check at the top of a JavaScript file, then describe important shapes with JSDoc:
// @ts-check
/**
* @typedef {object} ReceiptLine
* @property {string} sku
* @property {number} priceCents
* @property {number} quantity
*/
/**
* @param {ReceiptLine[]} lines
* @returns {number}
*/
function receiptTotal(lines) {
return lines.reduce((sum, line) => sum + line.priceCents * line.quantity, 0);
}
const total = receiptTotal([
{ sku: "pen", priceCents: 149, quantity: 2 },
{ sku: "notebook", priceCents: 1299, quantity: 1 },
]);This keeps JavaScript syntax while giving the checker useful information. It is not as clean as TypeScript syntax, but it is excellent for the in-between: legacy files, quick migrations, or libraries that still publish JavaScript source.
By itself, // @ts-check gives editor feedback for that file. To make .js files part of a project-level tsc --noEmit gate, TypeScript also needs allowJs in a config that includes those files:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"noEmit": true
},
"include": ["src/**/*.ts", "src/receipt-total.js"]
}allowJs: true lets TypeScript include JavaScript files in the project. checkJs: false means plain .js files are not all checked automatically; a file opts in with // @ts-check. The include list can stay narrow. Add one JavaScript file or folder when you are ready for it, not the whole legacy app on day one.
JSDoc is not a prize to collect forever. When a file starts to look like TypeScript written in comments, that is usually a sign it is ready to become .ts.
Put TypeScript in the toolchain
Vite can transform TypeScript source for the browser, but type checking should still be an explicit project command. Treat it like linting and formatting: a normal script everyone can run.
{
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"typecheck:migration": "tsc -p tsconfig.migration.json"
}
}The daily loop becomes:
npm run dev
npm run typecheck
npm run typecheck:migration
npm run builddev is for local feedback. typecheck asks TypeScript whether the TypeScript project is coherent. typecheck:migration adds the JavaScript files you have opted into the migration config. build produces deployable output. Keeping type checks separate makes the safety layer visible, and it gives CI simple commands to run.
You can also combine scripts when a project wants one release gate:
{
"scripts": {
"check": "npm run typecheck && npm run typecheck:migration && npm run build"
}
}The exact script names are a team convention. The important part is that type checking is a command, not a vibe.
Make CI enforce the ratchet
CI means continuous integration: checks that run on a shared service when code is pushed or a pull request opens. CI turns "please remember to type-check" into a repeatable gate.
A small GitHub Actions workflow can look like this:
name: Checks
on:
pull_request:
push:
branches: [main]
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm run typecheck:migration
- run: npm run buildNow the project has a shared rule: code that fails the type check does not quietly merge. That is the ratchet. Each migrated file increases the checked surface area, and CI keeps it from sliding backward.
Finish the taster
Section 20 gave you the professional TypeScript map: why types matter, the essential syntax, reusable generic patterns, utility types, unknown at boundaries, and a gradual adoption path. You now know enough to read TypeScript in real JavaScript projects without treating it as a separate universe.
For full practice, continue with the complete TypeScript course. Next in this JavaScript course, Section 21 moves from types to tests: another safety layer for changing code without guessing.
Checkpoint
Answer all three to mark this lesson complete