Quick start · project shape

Type-checked JavaScript in the browser, without a bundler

This is the setup for plain web pages: .js files loaded with <script type="module">, no bundler, no build step. The checker needs to know two things about that environment: the browser's APIs are there (lib with dom), and Node's are not (types: []).

  • Browser (no bundler)
  • 1 dev dependency
  • npm run lint

TypeScript has no option made for unbundled browser code. Its handbook suggests module: "nodenext" with "type": "module" in package.json, because that holds relative imports to full file names, which is what a browser needs to load them. The files below are one project, checked with TypeScript 5.9 when this site is built.

Install

TypeScript is the only package: it checks your files on your machine and in CI, and nothing from it is sent to the browser. A package.json is still useful, for the scripts and for "type": "module".

Terminal
npm init -y
npm pkg set type=module
npm install --save-dev typescript

jsconfig.json

lib lists the ECMAScript year your browsers support plus dom (and dom.iterable, so a NodeList works in for…of). types: [] keeps any @types package in node_modules from adding Node.js globals such as process, which would make code that crashes in a browser look fine.

module and moduleResolution set to nodenext, with "type": "module", make the checker refuse import "./counter" without its extension (TS2835), the same import a browser could not load.

jsconfig.json
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2022",
    "lib": ["es2022", "dom", "dom.iterable"],
    "types": []
  },
  "include": ["src/**/*.js"]
}

npm scripts

lint checks every file once; run it in CI. lint:watch checks again on every save.

package.json
{
  "type": "module",
  "scripts": {
    "lint": "tsc -p jsconfig.json",
    "lint:watch": "tsc -p jsconfig.json --watch --preserveWatchOutput"
  }
}

The page

One <script type="module"> loads the entry file; everything else is reached through import, and the checker follows the same imports.

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Counter</title>
    <script type="module" src="./src/main.js"></script>
  </head>
  <body>
    <button id="counter" type="button">Click me</button>
    <ul><li>One</li><li>Two</li></ul>
  </body>
</html>

What the checker knows about the page

It knows the DOM's types, not your HTML. document.querySelector("button") is an HTMLButtonElement | null: the tag name picks the element type, and null stays because the page may not have one, as the first example below shows. querySelector("#counter") is just an Element | null. An instanceof HTMLButtonElement check, as in the example project, narrows it to the type you need.

src/main.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: src/main.js(4,15): error TS2345: Argument of type 'HTMLButtonElement | null' is not assignable to parameter of type 'HTMLButtonElement'. Type 'null' is not assignable to type 'HTMLButtonElement'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. src/main.js(4,15): error TS2345: Argument of type 'HTMLButtonElement | null' is not assignable to parameter of type 'HTMLButtonElement'. Type 'null' is not assignable to type 'HTMLButtonElement'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. src/main.js(4,15): error TS2345: Argument of type 'HTMLButtonElement | null' is not assignable to parameter of type 'HTMLButtonElement'. Type 'null' is not assignable to type 'HTMLButtonElement'. checked 21 October 2026
import { attachCounter } from "./counter.js";

attachCounter(document.querySelector("button"));error TS2345: Argument of type 'HTMLButtonElement | null' is not assignable to parameter of type 'HTMLButtonElement'.
  Type 'null' is not assignable to type 'HTMLButtonElement'.
querySelector may find nothing.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2022",
    "lib": [
      "es2022",
      "dom",
      "dom.iterable"
    ],
    "types": []
  },
  "include": [
    "src/**/*.js"
  ]
}
src/main.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: src/main.js(2,31): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './counter.js'? 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. src/main.js(2,31): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './counter.js'? 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. src/main.js(2,31): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './counter.js'? checked 21 October 2026
import { attachCounter } from "./counter";error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './counter.js'?

const button = document.querySelector("#counter");
if (button instanceof HTMLButtonElement) attachCounter(button);
An import the browser could not load: the extension is missing.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2022",
    "lib": [
      "es2022",
      "dom",
      "dom.iterable"
    ],
    "types": []
  },
  "include": [
    "src/**/*.js"
  ]
}

npm packages without a bundler

A browser cannot resolve import "lodash" by itself; an import map in the page can map the name to a URL. TypeScript does not read import maps. Its handbook suggests paths to point the checker at type declarations for such names, and installing the package's types with npm so there is something to point at.

The example project

src/main.js finds a button and passes it to attachCounter from src/counter.js, after narrowing it with instanceof.

src/main.js TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
import { attachCounter } from "./counter.js";

const button = document.querySelector("#counter");
if (button instanceof HTMLButtonElement) attachCounter(button);

for (const item of document.querySelectorAll("li")) {
  item.classList.add("ready");
}
The page script, which narrows the element it finds.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2022",
    "lib": [
      "es2022",
      "dom",
      "dom.iterable"
    ],
    "types": []
  },
  "include": [
    "src/**/*.js"
  ]
}
src/counter.js TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
/**
 * Count clicks on a button and show the count on it.
 * @param {HTMLButtonElement} button
 */
export function attachCounter(button) {
  let count = 0;
  button.addEventListener("click", () => {
    count += 1;
    button.textContent = `Clicked ${count} times`;
  });
}
The module it imports, typed with JSDoc.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2022",
    "lib": [
      "es2022",
      "dom",
      "dom.iterable"
    ],
    "types": []
  },
  "include": [
    "src/**/*.js"
  ]
}

In your editor

VS Code reads this jsconfig.json with no further setup. To make it use the same TypeScript as npm run lint, see VS Code.

Sources

Last reviewed 20 October 2026.