Command Palette

Search for a command to run...

Hectal
PHASE 12Intermediate ~24 min· topic 5 of 6

Topic 12.5

Ways to Run TypeScript

In one line

No engine runs TypeScript directly: the types are always removed first. You can do that with tsc, with Node's built-in type stripping, or with a fast tool like esbuild or Vite, and only tsc itself checks the types, so a pipeline needs both jobs covered.

Think of it like this

A recipe is written in a fancy notation with notes in the margin ("check that the oven is 200 degrees"). Cooks can follow the recipe once someone erases the margin notes. A proofreader reads the notes and says "this step is wrong" but doesn't cook. A copier just erases the notes quickly without reading them. Running TypeScript is the same: checking (the proofreader, tsc) and removing types (the copier) are two different jobs, and you need both.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Type stripping
Removing type syntax (annotations, interfaces, type aliases) and leaving the JavaScript. Fast, because it needs no type information.
Erasable syntax
TypeScript syntax that exists only for types, so deleting it leaves valid JavaScript. Enums and parameter properties are not erasable.
erasableSyntaxOnly
A tsc flag that reports an error for any syntax that is not erasable.
Transpile
Convert code from one syntax to another without checking it. esbuild and SWC transpile TypeScript.
tsx
A command-line tool that runs TypeScript files in Node by transpiling them with esbuild first. No type checking.
Bundler
A tool (Vite, esbuild, webpack) that joins many files into a few files for the browser. Most also strip types.
tsc --noEmit
Run the full type check and write nothing. The usual CI step.
isolatedModules
A flag that makes tsc warn about code that tools processing one file at a time could not compile correctly.

Step by step

01Option 1: compile with tsc, run with node

A small file hello.ts with an interface, a typed function and a : number annotation. tsc checks it and writes JavaScript. Look at what disappeared: the interface and every : type. Nothing else changed.

terminalwhole filebash
$ tsc --ignoreConfig --outDir out --target es2022 --module commonjs hello.ts
$ cat out/hello.js
"use strict";
function greet(user) {
    return `Hello, ${user.name}`;
}
const price = "100";
console.log(greet({ name: "Asha" }), typeof price);
$ node out/hello.js
Hello, Asha string

02Option 2: let Node strip the types

The same file runs directly. Node 24.11 needs no flag. Then try a file with an enum or a constructor parameter property: Node refuses, because those need generated code, not just erasing.

terminalwhole filebash
$ node hello.ts
Hello, Asha string

$ cat enum.ts
enum Color { Red, Green }
console.log(Color.Green);
$ node enum.ts
.../enum.ts:1
enum Color { Red, Green }
^^^^^^^^^^^^^^^^^^^^^^^^^
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
    ...

$ cat ns.ts
class P {
  constructor(private x: number) {}
}
console.log(new P(1));
$ node ns.ts
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode

$ node --experimental-transform-types enum.ts
(node:...) ExperimentalWarning: Transform Types is an experimental feature and might change at any time
1

03Let tsc warn you about the same syntax

erasableSyntaxOnly turns Node's runtime failure into a compile-time error at the exact spot, in your editor, before you run anything. (Both lines are the same code that Node refused above.)

terminalwhole filebash
$ tsc --ignoreConfig --noEmit --erasableSyntaxOnly enum.ts ns.ts
enum.ts(1,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
ns.ts(2,15): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.

04Imports: the extension must be real

Node runs real files, so import { pi } from "./lib.ts" works and from "./lib" fails with ERR_MODULE_NOT_FOUND. tsc has its own opinion about the .ts spelling: it is an error unless you opt in with allowImportingTsExtensions (for noEmit setups), or ask tsc to rewrite the extension in its output with rewriteRelativeImportExtensions.

terminalwhole filebash
$ node a.ts          # a.ts has: import { pi } from "./lib.ts"
3.14
(node:...) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///.../a.ts is not specified and it doesn't parse as CommonJS.
...
$ node b.ts          # b.ts has: import { pi } from "./lib"
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../lib' imported from .../b.ts
  code: 'ERR_MODULE_NOT_FOUND',

$ tsc --ignoreConfig --noEmit --module nodenext a.ts
a.ts(1,20): error TS5097: An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.
$ tsc --ignoreConfig --noEmit --module nodenext --allowImportingTsExtensions a.ts
(no output, exit 0)
$ tsc --ignoreConfig --module nodenext --rewriteRelativeImportExtensions --outDir out a.ts lib.ts
$ cat out/a.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const lib_ts_1 = require("./lib.js");
console.log(lib_ts_1.pi);

05Option 3: esbuild erases the types, and checks nothing

bad.ts has const n: number = "not a number";. tsc --noEmit stops with an error. Node's type stripping and esbuild both just run or convert it. The types are advice that only tsc enforces. (tsx and Vite use esbuild's approach for TypeScript; they were not run for this lesson, so no output of theirs is shown here.)

terminalwhole filebash
$ tsc --ignoreConfig --noEmit --strict bad.ts ; echo "exit $?"
bad.ts(1,7): error TS2322: Type 'string' is not assignable to type 'number'.
exit 1

$ node bad.ts ; echo "exit $?"
not a number
exit 0

$ esbuild bad.ts
"use strict";
const n = "not a number";
console.log(n);
$ esbuild hello.ts --format=esm
function greet(user) {
  return `Hello, ${user.name}`;
}
const price = "100";
console.log(greet({ name: "Asha" }), typeof price);
Option 3: esbuild erases the types, and checks nothingdiagram
Rendering diagram…

06A sensible setup for an app

Development: Vite or tsx --watch for speed. Editor: the TypeScript language server for live errors. CI: tsc --noEmit (plus tests and a linter). Libraries: tsc to emit .js and .d.ts, or a bundler with a declaration plugin.

The tsc --init file you met in Topic 12.1 already matches this: verbatimModuleSyntax and isolatedModules keep your code safe for one-file-at-a-time tools, and noEmit is what your type-check script adds.

package.jsonwhole filejson
{
  "scripts": {
    "dev": "vite",
    "typecheck": "tsc --noEmit",
    "build": "vite build",
    "test": "vitest run"
  }
}

Try it yourself

  1. 1

    Sort the syntax

    For each of these, say whether Node's type stripping can run it: let x: number = 1;, interface A { a: string }, enum E { A }, class C { constructor(private n: number) {} }, function f<T>(x: T): T { return x; }, type U = string | number;. Answer: yes, yes, no, no, yes, yes. Only the enum and the parameter property generate code.

  2. 2

    Port the enum

    In the first example, replace only the enum by a const object with as const and a type Level = ... line. One of the four errors disappears. Do the same for the rest, one at a time.

  3. 3

    Predict the exit code

    A file has a type error. Predict the exit code of tsc --noEmit, of node file.ts and of esbuild file.ts. Check your answer against the terminal step above: 1, 0, 0.

Code & diagrams

erasableSyntaxOnly: syntax that cannot just be deleted TypeScript 5.8+

Four marked spots: the enum, the namespace with a value, and the two constructor parameter properties. Each needs the compiler to generate JavaScript, so a pure "blank it out" tool such as Node's type stripping cannot run it. In this live page the real compiler does generate it, so the console works. Without the override the example is clean.

Console

(nothing was printed)

Expected console output

1 4 1
The erasable rewrite: same behaviour, nothing to generate TypeScript 5.8+

A const object plus a union type replaces the enum (Topic 5.3), a plain object replaces the namespace, and explicit fields plus assignments replace the parameter properties. The output is identical and every type annotation can be deleted without changing the program, which is what Node's type stripping needs.

Console

(nothing was printed)

Expected console output

1 4 1
Types are erased: wrong types still run

The Types panel shows the same error that tsc --noEmit prints in the terminal step above, and the console shows the program ran. Like Node's stripping or esbuild, this page erases the annotation and runs the rest. Only the checker stops a wrong program from being merged.

Console

(nothing was printed)

Expected console output

not a number string

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Run an enum with Node

Put enum Color { Red, Green } in a file and run node enum.ts.

terminal
$ node enum.ts
── what you'll see ──
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode

Break #2

Import without the extension

In b.ts, write import { pi } from "./lib" and run node b.ts.

terminal
$ node b.ts
── what you'll see ──
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../lib' imported from .../b.ts

Myth vs fact

Myth

Running my TypeScript with tsx or Vite checks my types.

Fact

They remove types and run. Only tsc (or the editor's language server) checks them. Add tsc --noEmit to CI.

Myth

Node runs TypeScript, so I no longer need a tsconfig.

Fact

Node ignores tsconfig.json. You still want one for tsc --noEmit and your editor, with erasableSyntaxOnly so the two agree.

Myth

Type stripping supports all of TypeScript.

Fact

Only syntax that is purely types. Enums, value namespaces and parameter properties need code to be generated and are rejected.

Myth

Faster tools mean less type safety.

Fact

They split the job: a fast tool runs your code, tsc --noEmit checks it. Safety is the same if you run both.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Node replaces the stripped type syntax with whitespace, so stack traces and line/column numbers match your .ts file without a source map. That is a design reason for the "erasable only" rule: no transformation, no map.

  • ▸

    Node refuses to strip types from files inside node_modules, so a published package must ship JavaScript (and .d.ts files), never raw .ts. Your erasableSyntaxOnly setting only governs your own source for the same reason.

  • ▸

    isolatedModules exists because one-file tools can't see other files. Re-exporting a type with export { T } from "./x" looks like a value re-export to them; export type { T } (and verbatimModuleSyntax) makes the intent explicit, so they can drop it safely.

  • ▸

    For TypeScript 7, tools built on the compiler's programmatic API (typescript-eslint, framework language tools) stay on 6.0 through the @typescript/typescript6 package until a 7.x API arrives. Plan for two TypeScript versions in a large toolchain: 7.0 for tsc --noEmit, 6.0 for the tools that need the API.

Remember this

  1. 1

    The problem this solves: no engine understands TypeScript's types, so you need a way to turn a .ts file into something that runs, and a separate way to be sure the types are right. Remember from Topic 0.2: browsers, Node and Deno run JavaScript. TypeScript's types are erased before anything runs. So every way of "running TypeScript" has two separate questions: who erases the types, and who checks them? The answers differ, and mixing them up is why code with type errors sometimes runs happily.

  2. 2

    **Option 1: tsc then node.** tsc checks and emits .js files into outDir, and you run those. It is the most predictable, it supports every TypeScript feature and every target, and it is what you ship to production for libraries and servers. The cost is a build step and, in development, having to rebuild (tsc --watch) before running.

  3. 3

    Option 2: Node's built-in type stripping. Recent Node versions can run node file.ts directly: Node reads the file, blanks out the type syntax (replacing it with spaces so the line numbers stay the same) and runs the rest. In Node 24.11 (the version used to write this lesson) node hello.ts works with no flag, and process.features.typescript says strip. Rules to know: it does no type checking; it ignores tsconfig.json; it can only remove syntax that is pure types, so features that generate code (enum, namespace with values, constructor parameter properties like constructor(private x: number)) fail with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX; and relative imports must write the real extension (import { pi } from "./lib.ts"). A separate flag, --experimental-transform-types, also transforms those code-generating features, and it is still marked experimental. Check the Node documentation for the version you deploy on: this area is still moving.

  4. 4

    **erasableSyntaxOnly** (TypeScript 5.8) is the compiler flag that matches Node's rule. With it on, tsc reports an error (TS1294) on every piece of syntax that can't be removed by blanking it out: enums, namespaces that contain values, parameter properties, and the old import x = require(...) and export = forms. Turn it on if you want your code to run under Node's type stripping, or any other "just erase it" tool, without surprises. Pair it with allowImportingTsExtensions (so tsc accepts ./lib.ts in imports) or rewriteRelativeImportExtensions (so tsc rewrites ./lib.ts to ./lib.js in the output).

  5. 5

    Option 3: tools that strip types and bundle: tsx (runs TypeScript in Node, built on esbuild), esbuild, SWC, Babel's TypeScript plugin, Bun and Vite. They compile each file on its own, extremely fast, by deleting the types and transforming syntax, and almost none of them check types. That is the reason for the isolatedModules option from tsc --init: it makes tsc warn about code that a one-file-at-a-time tool would misread (for example re-exporting a type without saying it is a type, which verbatimModuleSyntax also pins down). A project built this way (Vite, Next.js, most React apps) runs tsc --noEmit separately for the checking.

  6. 6

    **tsc --noEmit in CI** is the standard answer to "who checks the types?". It does the full check, writes no files, and exits with a non-zero code when there are errors. Put it next to your tests and linter, and in your editor the language server does the same continuously. A dev server (Vite, tsx --watch) shows your page even when types are wrong; CI is what prevents wrong types from merging. With TypeScript 7.0 the check is about ten times faster, so running it on every commit is no longer a burden.

Explain it without notes

01

Who removes the types and who checks them in a Vite or tsx setup?

02

What does Node's built-in type stripping support, and what does it reject?

03

What is erasableSyntaxOnly and when would you turn it on?

04

Why does a project that builds with a bundler still run tsc --noEmit?

05

What do isolatedModules and verbatimModuleSyntax protect against?

Practice

01

Rewrite this class so it passes erasableSyntaxOnly, keeping the same behaviour: ``ts class Account { constructor(private owner: string, public balance = 0) {} }

02

Write the three package.json scripts for a Vite app: dev server, production build and a CI-only type check. Say which one will fail on a type error and which will not.

Trade-offs

  • ↔

    Compiling with tsc is slow but complete and predictable. Strip-only runs are instant but limited, and checking must be done elsewhere. Use the fast path in development and the complete one before merging.

  • ↔

    Banning enums and parameter properties (erasableSyntaxOnly) makes code portable across fast tools, at the cost of a little more typing and of porting old code.

Done when you can

  • I can explain the two jobs: erasing types and checking them.

  • I can run a .ts file with tsc plus node, and with Node's type stripping.

  • I know which TypeScript syntax Node's stripping rejects, and why.

  • I can turn on erasableSyntaxOnly and rewrite an enum and a parameter property.

  • I can say why Vite, tsx and esbuild don't catch type errors, and where I put tsc --noEmit.