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".
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.
{
"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.
{
"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.
<!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.
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'.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"
]
} 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);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.
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");
}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"
]
} /**
* 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`;
});
}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
- Modules: Choosing Compiler Options · TypeScript handbook
- TSConfig Reference: lib · TypeScript
- JavaScript modules · MDN
- Document: querySelector() method · MDN
Last reviewed 20 October 2026.