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:

  1. Compile with tsc, run the output. The compiler checks the types and writes .js files, which Node.js runs. This is the route on setting up tsc, and the one the examples on this site use.
  2. 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.
  3. 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.
Three routes from TypeScript source to running code. First, tsc checks types and emits JavaScript that Node.js runs. Second, Node.js, Deno, Bun or tsx strip the types and run the code without checking. Third, a bundler such as Vite or esbuild strips types and bundles modules into files for the browser, also without checking. A separate tsc --noEmit step checks the types for the second and third routes. app.ts tsc checks + emits .js Node.js · Deno · Bun · tsx strips types, no check Vite · esbuild strips + bundles, no check node app.js runs in memory bundle.js in the browser + tsc --noEmit for routes 2 and 3
Only 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 --noEmit in the editor, before commits and in the pipeline.
  • tsconfig.json is ignored. Options such as a lower target have no effect, and imports that rely on paths fail (Node.js offers subpath imports starting with # instead).
  • Extensions are required: import { cost } from "./tariffs.ts". Use the compiler option rewriteRelativeImportExtensions (or allowImportingTsExtensions with noEmit) so that tsc accepts the same imports.
  • Type imports need type. Node.js cannot tell a type from a value in another file, so it keeps every import without type, and importing an interface that way fails at run time. See modules and declaration files.
  • Module system: .ts files follow "type" in package.json, .mts is always an ES module and .cts always CommonJS. .tsx files 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),
  • namespace with code in it,
  • parameter properties such as constructor(public id: string) (see classes),
  • import x = require("…") and export =,
  • 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 --noEmit as 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.json in line with the tool that runs the code (erasableSyntaxOnly for Node.js, isolatedModules for bundlers).

Tooling, configuration and migrating from JavaScript covers the options and the pipeline setup in more detail.