TypeScript in Node.js and the Browser
Running and building TypeScript in practice: Node.js type stripping and tsx, bundlers for the browser, DOM types, and source maps for debugging.
Three routes from .ts to running code
A browser or a JavaScript engine only understands JavaScript. As what is TypeScript? explains, the types must be removed before the code runs. In practice there are three routes, and many projects use more than one:
-
Compile with
tsc, run the output. The compiler checks the types and writes.jsfiles, which Node.js runs. This is the route on setting up tsc, and the one the examples on this site use. - Strip the types when the program starts. Node.js itself, Deno, Bun or the tool tsx remove the types in memory and run the result. No build step, no output folder.
- Bundle for the browser. A bundler such as Vite or esbuild strips the types, follows the imports, and writes a few optimized JavaScript files that a web page loads.
tsc checks types. The faster routes skip the
check, so a project that uses them runs
tsc --noEmit separately.
Running .ts files in Node.js
Recent Node.js versions run TypeScript files directly with
type stripping: node billing.ts just works. Type stripping is on by
default since Node.js 23.6 and 22.18, and stable since 25.2 and
24.12; that page has the full version history. Node.js 20, which is
end-of-life, cannot do it. The examples on this site still go
through tsc first, because they need the type check.
The documentation describes exactly what Node.js does: it replaces
the TypeScript syntax with whitespace and performs no type checking.
A file like this:
interface MeterReading {
meterId: string;
kWh: number;
}
function cost(reading: MeterReading, pricePerKWh: number): number {
return reading.kWh * pricePerKWh;
}
runs, in effect, as:
function cost(reading , pricePerKWh ) {
return reading.kWh * pricePerKWh;
}
Because every character stays in its place, line and column numbers
in error messages still match the .ts file, and no
source map is needed. The approach has deliberate limits:
-
No type checking. A file full of type errors runs
anyway. Run
tsc --noEmitin the editor, before commits and in the pipeline. -
tsconfig.jsonis ignored. Options such as a lowertargethave no effect, and imports that rely onpathsfail (Node.js offers subpath imports starting with#instead). -
Extensions are required:
import { cost } from "./tariffs.ts". Use the compiler optionrewriteRelativeImportExtensions(orallowImportingTsExtensionswithnoEmit) so thattscaccepts the same imports. -
Type imports need
type. Node.js cannot tell a type from a value in another file, so it keeps every import withouttype, and importing an interface that way fails at run time. See modules and declaration files. -
Module system:
.tsfiles follow"type"inpackage.json,.mtsis always an ES module and.ctsalways CommonJS..tsxfiles are not supported. -
Not in dependencies. Node.js refuses to strip
types in files under
node_modules; packages must publish JavaScript.
The Node.js documentation recommends TypeScript 5.8 or newer with
these settings, so that the compiler checks what Node.js will run
(noEmit when you only run the .ts files
and never publish .js):
{
"compilerOptions": {
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true
}
}
Erasable syntax
Erasable syntax is TypeScript
syntax that can be deleted, leaving valid JavaScript with the same
behavior: annotations, interfaces, type aliases,
import type, as and
satisfies, generics. A few older features are not
erasable, because the compiler generates JavaScript for them:
-
enum(compiles to an object; see enums), namespacewith code in it,-
parameter properties such as
constructor(public id: string)(see classes), -
import x = require("…")andexport =, - the old cast syntax
<string>value.
Node.js stops with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
when it meets one of them (decorators even give a parser error). The
compiler option
erasableSyntaxOnly, added in TypeScript 5.8, reports every use of non-erasable syntax
as an error, so you find them while editing instead of at start-up:
src/status.ts(1,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
Each feature has an erasable replacement. An object with
as const instead of an enum:
// Instead of: enum TicketStatus { Open = "open", Resolved = "resolved" }
const TicketStatus = {
Open: "open",
Resolved: "resolved",
} as const;
type TicketStatus = (typeof TicketStatus)[keyof typeof TicketStatus];
function label(status: TicketStatus): string {
return status === TicketStatus.Open ? "Open" : "Resolved";
}
console.log(label(TicketStatus.Open), label("resolved"));
Open Resolved
Explicit fields instead of parameter properties:
// Instead of: constructor(public readonly id: string, private title: string) {}
class Ticket {
readonly id: string;
private title: string;
constructor(id: string, title: string) {
this.id = id;
this.title = title;
}
describe(): string {
return `${this.id}: ${this.title}`;
}
}
console.log(new Ticket("INC-30412", "Portal login slow").describe());
INC-30412: Portal login slow
These rewrites are a little longer, but they are plain JavaScript plus types, which keeps the code runnable by every tool, now and if JavaScript ever gets type syntax of its own (the TC39 "Type Annotations" proposal is still at Stage 1 as of October 2026).
tsx, Deno, Bun and transpilers
A transpiler is a compiler that
translates source code into source code of another language or
version at the same level, here TypeScript into JavaScript. In the
TypeScript world the word usually means a fast tool that removes the
types file by file without checking them: esbuild, SWC, Oxc and
Babel are transpilers. tsc also transpiles, but it
reads the whole program, which makes the type check possible and
makes it slower.
Because a transpiler looks at one file at a time, it cannot know
whether an imported name is a type. The option
isolatedModules makes tsc report code that
a single-file transpiler would get wrong; tools such as Vite require
it. Runners built on transpilers, as of October 2026:
| Tool | What it does | Type check |
|---|---|---|
| Node.js type stripping | runs .ts files with erasable syntax |
no |
| tsx (version 4.x) |
runs any TypeScript in Node.js, including enums and
tsconfig.json paths, using esbuild:
npx tsx billing.ts
|
no |
| Deno (2.x) |
a runtime with built-in TypeScript support:
deno run strips types;
deno check or deno run --check
type-checks
|
on request |
| Bun (1.x) |
a runtime that transpiles .ts and
.tsx on the fly
|
no; use tsc |
Older tutorials use ts-node. Its last release dates from December 2023 and it is effectively unmaintained; for new projects, use Node.js type stripping or tsx. See Deno and Bun for those runtimes in general.
TypeScript in the browser: bundlers
No browser runs TypeScript, and a browser loads every module as a
separate HTTP request. Front-end projects therefore use a
bundler: a build tool that starts
from an entry file, follows all its imports (including packages in
node_modules), and combines them into a small number of
JavaScript files for the browser. Along the way it transpiles
TypeScript, leaves out unused code, and minifies the result. Widely
used bundlers as of October 2026 are Vite (version 8, which uses the
Oxc transformer for TypeScript), esbuild, webpack and Rollup.
Bundlers do not check types either. Vite's documentation is explicit
that it only transpiles .ts files, and asks for
isolatedModules. A typical project therefore runs the
type checker in its build script, and in the editor while
developing:
{
"scripts": {
"dev": "vite",
"typecheck": "tsc --noEmit",
"build": "tsc --noEmit && vite build"
}
}
With &&, the build only runs when the type
check passes. For bundled code, set
"moduleResolution": "bundler" in
tsconfig.json, so that the compiler resolves imports
the way the bundler does (see
module resolution).
DOM types
The DOM lib is the set
of declaration files, selected with "dom" in the
lib compiler option, that describes the browser's
JavaScript interfaces: document, window,
HTMLInputElement, events, fetch and the
rest of the
Document Object Model
and web APIs. When lib is not set, the compiler
includes it by default, together with the standard library that
matches the target.
The DOM types are honest about two things that cause many bugs.
First, a query can find nothing:
document.querySelector() returns
Element | null:
const input = document.querySelector("#kwh");
console.log(input.value);
example.ts(3,13): error TS18047: 'input' is possibly 'null'.
Second, the compiler cannot know which element a CSS
selector finds. Element is the most general type, and
it has no value. Even with the null check
silenced by !, the next error remains:
const input = document.querySelector("#kwh")!;
console.log(input.value);
example.ts(3,19): error TS2339: Property 'value' does not exist on type 'Element'.
Three ways to get the right element type. A query by tag name
already knows it (querySelector("form") gives
HTMLFormElement | null); otherwise, pass the type as a
type argument, and check for null before use. This
browser code is type-checked, but not run here (Node.js has no
document):
const input = document.querySelector<HTMLInputElement>("#kwh");
const output = document.querySelector("output");
const form = document.querySelector("form");
if (input && output && form) {
form.addEventListener("submit", (event) => {
event.preventDefault();
const kWh = input.valueAsNumber;
output.textContent = Number.isFinite(kWh) ? `€${(kWh * 0.32).toFixed(2)}` : "Enter a number";
});
}
The type argument in
querySelector<HTMLInputElement> is an unchecked
promise, like as: if #kwh turns out to be
a <select>, nothing warns you. The safest way is
instanceof, which checks at run time and narrows the
type at the same time. It is the usual pattern for event targets,
which are typed as the general EventTarget | null:
document.addEventListener("input", (event) => {
const target = event.target;
if (target instanceof HTMLInputElement && target.name === "kwh") {
console.log(target.valueAsNumber);
}
});
Node.js types
For Node.js, the types come from the
@types/node
package, which you list in "types": ["node"]. Its major
version should match the Node.js version you run. Like the DOM
types, it tells the truth about missing values: an environment
variable may not be set, so every value in
process.env is string | undefined:
const region: string = process.env.KESTREL_REGION;
console.log(region);
example.ts(1,7): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
Give each setting a default, or stop with a clear message at start-up when a required one is missing:
// process.env values are string | undefined: the variable may not be set.
const region = process.env.KESTREL_REGION ?? "NL";
const maxRetries = Number(process.env.KESTREL_MAX_RETRIES ?? "3");
console.log(`Region ${region}, up to ${maxRetries} retries`);
Region NL, up to 3 retries
The Node.js APIs are typed in detail, often with overloads. Here
readFile returns a Buffer (bytes) without
an encoding and a string with one, and the compiler
knows which you get:
import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const dir = await mkdtemp(join(tmpdir(), "kestrel-"));
const file = join(dir, "readings.csv");
await writeFile(file, "E-1001,4.2\nE-1002,3.1\n");
const bytes = await readFile(file); // Buffer: no encoding given
const text = await readFile(file, "utf8"); // string: encoding given
console.log(bytes.length, "bytes");
const total = text
.trim()
.split("\n")
.map((line) => Number(line.split(",")[1]))
.reduce((sum, kWh) => sum + kWh, 0);
console.log(`Total ${total.toFixed(1)} kWh`);
await rm(dir, { recursive: true });
22 bytes Total 7.3 kWh
Source maps and debugging
When tsc or a bundler produces JavaScript, the code
that runs is not the code you wrote. A stack trace then points to
lines in the output. A
source map
fixes that: the compiler option sourceMap writes a
.js.map file next to each .js file and
adds a comment that points to it:
interface MeterReading {
meterId: string;
kWh: number;
}
function cost(reading: MeterReading, pricePerKWh: number): number {
if (reading.kWh < 0) {
throw new RangeError(`Negative reading for ${reading.meterId}`);
}
return reading.kWh * pricePerKWh;
}
console.log(cost({ meterId: "E-1001", kWh: -4.2 }, 0.32).toFixed(2));
npx tsc --sourceMap
node dist/billing.js
node --enable-source-maps dist/billing.js
RangeError: Negative reading for E-1001
at cost (/home/ravi/billing/dist/billing.js:5:15)
at Object.<anonymous> (/home/ravi/billing/dist/billing.js:9:13)
RangeError: Negative reading for E-1001
at cost (/home/ravi/billing/src/billing.ts:8:11)
at Object.<anonymous> (/home/ravi/billing/src/billing.ts:13:13)
Without the flag, Node.js reports line 5 of the emitted
billing.js; with --enable-source-maps,
line 8 of billing.ts, where the
throw really is. Browser developer tools and editors
such as VS Code use the same maps to show and step through the
TypeScript source while the JavaScript runs. With Node.js type
stripping, no map is needed, because the positions never change.
Checking still matters
Every fast route on this page skips the type check. That is a reasonable trade-off, because checking the whole program takes longer than stripping one file, and your editor checks while you type. But it means that nothing stops code with type errors from running unless you add the check yourself:
-
run
tsc --noEmitas a script (npm run typecheck) before builds and commits; - make it a required step in the CI pipeline, so a type error blocks the merge;
-
keep the compiler options in
tsconfig.jsonin line with the tool that runs the code (erasableSyntaxOnlyfor Node.js,isolatedModulesfor bundlers).
Tooling, configuration and migrating from JavaScript covers the options and the pipeline setup in more detail.