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.
$ 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 string02Option 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.
$ 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
103Let 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.)
$ 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.
$ 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.)
$ 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);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.
{
"scripts": {
"dev": "vite",
"typecheck": "tsc --noEmit",
"build": "vite build",
"test": "vitest run"
}
}Try it yourself
- 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
Port the enum
In the first example, replace only the
enumby aconstobject withas constand atype Level = ...line. One of the four errors disappears. Do the same for the rest, one at a time. - 3
Predict the exit code
A file has a type error. Predict the exit code of
tsc --noEmit, ofnode file.tsand ofesbuild file.ts. Check your answer against the terminal step above: 1, 0, 0.
Code & diagrams
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 1A 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 1The 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 stringBreak 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.
Break #2
Import without the extension
In b.ts, write import { pi } from "./lib" and run node 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
.tsfile 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.tsfiles), never raw.ts. YourerasableSyntaxOnlysetting only governs your own source for the same reason. - ▸
isolatedModulesexists because one-file tools can't see other files. Re-exporting a type withexport { T } from "./x"looks like a value re-export to them;export type { T }(andverbatimModuleSyntax) 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/typescript6package until a 7.x API arrives. Plan for two TypeScript versions in a large toolchain: 7.0 fortsc --noEmit, 6.0 for the tools that need the API.
Remember this
- 1
The problem this solves: no engine understands TypeScript's types, so you need a way to turn a
.tsfile 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
**Option 1:
tscthennode.**tscchecks and emits.jsfiles intooutDir, and you run those. It is the most predictable, it supports every TypeScript feature and everytarget, 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
Option 2: Node's built-in type stripping. Recent Node versions can run
node file.tsdirectly: 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.tsworks with no flag, andprocess.features.typescriptsaysstrip. Rules to know: it does no type checking; it ignorestsconfig.json; it can only remove syntax that is pure types, so features that generate code (enum,namespacewith values, constructor parameter properties likeconstructor(private x: number)) fail withERR_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
**
erasableSyntaxOnly** (TypeScript 5.8) is the compiler flag that matches Node's rule. With it on,tscreports 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 oldimport x = require(...)andexport =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 withallowImportingTsExtensions(sotscaccepts./lib.tsin imports) orrewriteRelativeImportExtensions(sotscrewrites./lib.tsto./lib.jsin the output). - 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 theisolatedModulesoption fromtsc --init: it makestscwarn about code that a one-file-at-a-time tool would misread (for example re-exporting a type without saying it is a type, whichverbatimModuleSyntaxalso pins down). A project built this way (Vite, Next.js, most React apps) runstsc --noEmitseparately for the checking. - 6
**
tsc --noEmitin 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
Who removes the types and who checks them in a Vite or tsx setup?
What does Node's built-in type stripping support, and what does it reject?
What is erasableSyntaxOnly and when would you turn it on?
Why does a project that builds with a bundler still run tsc --noEmit?
What do isolatedModules and verbatimModuleSyntax protect against?
Practice
Rewrite this class so it passes erasableSyntaxOnly, keeping the same behaviour:
``ts
class Account {
constructor(private owner: string, public balance = 0) {}
}
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
tscis 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
.tsfile withtscplusnode, and with Node's type stripping.I know which TypeScript syntax Node's stripping rejects, and why.
I can turn on
erasableSyntaxOnlyand rewrite an enum and a parameter property.I can say why Vite,
tsxand esbuild don't catch type errors, and where I puttsc --noEmit.