skip to content

JavaScript Interop & Migration

You will learn how TypeScript type-checks plain JavaScript and how a team moves a real codebase over file by file instead of attempting a big-bang rewrite. Interviewers ask because most TypeScript adoption happens on an existing JavaScript project, not a greenfield one.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

explore

questions

10

In a tsconfig.json, what does the TypeScript compiler option `allowJs` do on its own, and what changes when you also turn on `checkJs`?

level: juniorimportance: must knowfreq 58%

answer

  1. two flags, two different jobs
  2. one admits files, the other checks them
  3. inference happens either way
  4. emit means output can hit input
  5. per-file comments override the project switch

basics

~20 s

allowJs admits .js and .jsx files into the TypeScript program so they are resolved, inferred from and emitted — but no errors are reported in them. checkJs additionally turns the type checker on for those JavaScript files.

solid answer

~50 s

By default `tsc` only puts `.ts`, `.tsx` and `.d.ts` files into the program. `allowJs` adds `.js` and `.jsx`: they become real inputs, so imports to them resolve, the compiler infers types from their code and JSDoc, and it emits them through the normal pipeline honouring `target` and `module`. What `allowJs` does *not* do is report a single type error inside them. `checkJs` is the second half — it tells the checker to report diagnostics in JavaScript files exactly as it does in TypeScript files, using whatever it can infer plus JSDoc comments. `checkJs` only means something once `allowJs` has admitted the files; there is nothing to check otherwise. You can also opt individual files in or out with the `// @ts-check` and `// @ts-nocheck` comments. One practical trap: because `allowJs` makes `tsc` emit JavaScript, you need an `outDir` distinct from your sources or `noEmit`, otherwise output would land on top of the inputs.

code

json · 11 lines
json
{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": true,
    "strict": true,
    "target": "ES2022",
    "module": "ESNext",
    "outDir": "./dist"
  },
  "include": ["src"]
}

go deeper

for a junior

Know the one-line split: allowJs lets JavaScript into the compilation, checkJs makes the compiler complain about it. Say plainly that allowJs alone reports no errors.

for a middle

Explain the mechanics: the program's accepted extensions, inference from JSDoc and code, emit through target/module, and why an outDir separate from the sources is needed once JavaScript is an input.

for a senior

Show judgment about rollout: allowJs first so TypeScript consumers stop seeing any, then checking file-by-file with // @ts-check, and a non-emitting type-check job so a checking failure never blocks the build pipeline.

for a principal

Own the policy question — whether JavaScript stays a permanent first-class citizen of the build or is a transitional state, what the compiler is authoritative for versus the bundler, and how that choice shapes build time and CI topology.

## What "the program" is When `tsc` runs, it first builds a **program**: the set of source files it will parse and reason about. That set starts from `files`/`include` in `tsconfig.json` and grows transitively through every import it can resolve. Out of the box the program only accepts `.ts`, `.tsx` and `.d.ts`. A `.js` file sitting next to them is invisible to the compiler. That invisibility has a consequence people meet before they ever read the flag list: from a `.ts` file, `import { format } from './util.js'` finds no declaration for `./util`, so the import is an implicit `any` — and an outright error under `noImplicitAny`. ## allowJs: admission, inference, emit `allowJs: true` adds `.js` and `.jsx` to the accepted extensions. Three things follow. **Resolution.** Imports pointing at JavaScript now resolve to real source files instead of dead ends. **Inference.** TypeScript reads the JavaScript and infers types from it — literal initialisers, function bodies, and any JSDoc comments already present. Consumers do not get `any`; they get a best-effort signature. This is why `allowJs` alone is often worth turning on: your existing TypeScript gains real types for the JavaScript it calls. **Emit.** The `.js` files are now compiler *inputs*, so they are also compiler *outputs*: downlevelled to `target`, module syntax rewritten per `module`, comments and helpers handled the same way. Since inputs and outputs are both JavaScript, they can collide, and the compiler refuses rather than clobbering your source: ``` error TS5055: Cannot write file '/app/src/util.js' because it would overwrite input file. ``` The fix is an `outDir` that is not the source directory, or `noEmit: true` if another tool (a bundler, `tsx`, Babel) does the actual building and `tsc` is only your type-check gate. What `allowJs` deliberately does **not** do is report errors in those files. A `.js` file can call an undefined function or pass a string where a number is expected and the build stays green. ## checkJs: turning the checker on `checkJs: true` flips that last part: the checker now reports diagnostics in JavaScript files with the same rules it applies to TypeScript. Unresolved names, wrong argument types, property access on a possibly-undefined value under `strictNullChecks` — all of it surfaces. The rules are the same; the *information* is different. A `.js` file cannot carry type syntax — `function f(x: string)` is a syntax error in JavaScript — so everything the checker knows comes from three places: the code itself, initialisers, and JSDoc comments. Adding `@param`/`@returns`/`@typedef` is how you feed it more. `checkJs` is a project-wide switch, but checking is decided per file, and two comments override it: ```js // @ts-check // this file is checked even when checkJs is off ``` ```js // @ts-nocheck // this file is skipped even when checkJs is on ``` That pairing is what makes the flag usable on a large codebase: turn `checkJs` on and exempt the handful of files that are not ready, or leave it off and opt files in one at a time. ## Choosing a combination - **Neither flag.** A pure TypeScript project. Any stray `.js` is outside the program. - **`allowJs` only.** JavaScript participates and is emitted, TypeScript consumers get inferred types, nothing in the JavaScript is enforced. A very cheap first step. - **`allowJs` + `checkJs`.** The JavaScript is genuinely type-checked. Expect errors immediately; that is the point. - **`allowJs` + `// @ts-check` per file.** Same enforcement, file by file, with the project switch still off. The strictness flags stack on top of whichever you choose: with `strict` on, a checked `.js` file is held to `strictNullChecks` and the rest just like a `.ts` file. ## Editors The same machinery powers editors that use the TypeScript language service, which is why a plain `.js` file with `// @ts-check` at the top starts showing red squiggles in VS Code with no build step involved. The compiler flags decide what your *build* enforces; the comment decides what a single file enforces everywhere.

  • With `allowJs` on but `checkJs` off, what type does a `.ts` file see when it imports a function from a `.js` file?
    An inferred signature, not `any`. The compiler parses the JavaScript, infers parameter and return types from the body, defaults and any JSDoc, and hands that to the importer. It simply never reports errors found *inside* that JavaScript file. Without `allowJs` at all, the import has no declaration to read and degrades to an implicit `any`.
  • Right after enabling `allowJs`, `tsc` fails with "Cannot write file ... because it would overwrite input file". What happened?
    The `.js` sources are now compiler inputs, so the compiler wants to emit `.js` outputs — and with no `outDir` (or an `outDir` equal to the source root) the output path is the input path. Point `outDir` at a separate build directory, or set `noEmit: true` if `tsc` is only your type-check gate and something else builds.
  • Does turning on `checkJs` change what the compiler emits?
    No. `checkJs` is purely a diagnostics switch; emit is governed by `allowJs`, `target`, `module`, `outDir` and friends. A project can fail type-checking under `checkJs` and still produce identical output to before — which is exactly why teams sometimes run `checkJs` in a separate non-emitting check job.

saying these in an interview costs you the question

  • Says allowJs type-checks JavaScript files by itself
  • Thinks JavaScript files import as any even with allowJs on
  • Believes allowJs rewrites .js files into TypeScript
  • Expects checkJs to work without the files being in the program
  • Thinks you can write TypeScript annotations in a checked .js file

context

open as a page

In TypeScript, what is the difference between the `// @ts-expect-error` and `// @ts-ignore` comments, and why is one usually preferred?

level: middleimportance: must knowfreq 62%

basics

~20 s

Both suppress type errors on the line that follows them. // @ts-expect-error additionally requires an error to be there — if the line becomes valid, the compiler reports the directive as unused, so stale suppressions surface instead of rotting.

open as a page

In a JavaScript file that TypeScript is checking, how do you annotate a function's parameters and return type, declare a reusable named object type, and make a function generic — using only JSDoc?

level: middleimportance: must knowfreq 54%

basics

~20 s

Use JSDoc tags in comments: @param and @returns for a function's signature, @type for a variable or a cast, @typedef to declare a reusable named type, and @template to introduce a type parameter. TypeScript reads them as real types.

open as a page

In a project already compiled by tsc with allowJs enabled, you rename utils.js to utils.ts and change nothing inside the file. Does the JavaScript that ships behave any differently, and what does change?

level: juniorimportance: should knowfreq 40%

basics

~20 s

Behaviour does not change: TypeScript erases types, and a freshly renamed file has none to erase, so tsc emits the same JavaScript. What changes is checking — the file is now checked as TypeScript, so implicit anys and unsound patterns start appearing as errors.

open as a page

What do the `// @ts-check` and `// @ts-nocheck` comments do when placed at the top of a file, and how do they interact with TypeScript's `checkJs` compiler option?

level: middleimportance: should knowfreq 46%

basics

~20 s

Placed at the top of a file, // @ts-check makes TypeScript type-check that one file even when checkJs is off, and // @ts-nocheck makes it skip that file even when checking is on. Both override the project setting per file.

open as a page

When migrating a JavaScript codebase to TypeScript file by file with tsc, why is it usually better to convert leaf modules — the ones that import little or nothing else from the project — before converting the hub modules that most of the codebase imports?

level: middleimportance: should knowfreq 50%

basics

~20 s

Because a converted file's types are only as good as its dependencies. Converting leaves first means every new TypeScript file rests on already-typed code, so inference produces real types instead of weak ones you must revisit, and each change stays small and revertable.

open as a page

During a TypeScript migration, tsc reports errors that originate inside .d.ts files under node_modules. What does setting skipLibCheck to true in tsconfig.json actually skip, and what do you give up by leaving it on?

level: middleimportance: should knowfreq 45%

basics

~20 s

skipLibCheck stops tsc type-checking the contents of every declaration file — dependencies' and your own alike, including hand-written shims. Your code is still checked against those declarations, so what you give up is any warning that a declaration file is itself wrong or inconsistent.

open as a page

A half-migrated codebase sets "strict": true in tsconfig.json and tsc reports several thousand errors. How would you get the project to full strict without freezing feature work?

level: seniorimportance: should knowfreq 55%

basics

~20 s

Do not merge the all-at-once change. Stage it: keep strict on but switch individual flags back off and re-enable them one at a time, or scope strict to migrated directories, and make CI hold a baseline error count that is only ever allowed to fall.

open as a page

You own a large, actively developed JavaScript codebase and the team wants type safety. How would you decide between an incremental TypeScript migration, a rewrite in TypeScript, and staying on JavaScript with types checked by tsc?

level: principalimportance: should knowfreq 35%

basics

~20 s

Incremental is the default because the codebase stays shippable and every step is revertable. A rewrite is only defensible for a small, stable, well-covered module. Staying on typed JavaScript is the right answer when the build cannot change or the code ships as source.

open as a page

What does TypeScript's `maxNodeModuleJsDepth` compiler option control, why is its default 0, and what goes wrong when you raise it?

level: seniorimportance: nice to knowfreq 16%

basics

~20 s

maxNodeModuleJsDepth sets how many folder levels deep under node_modules TypeScript will load JavaScript files to infer types from. It defaults to 0 so dependency types come from declaration files, not from inferring over dependency source.

open as a page