Type checking vs type stripping
See why Vite, esbuild, and Node run TypeScript without checking it, what type stripping leaves behind, and how tsc --noEmit catches errors in builds and CI.
- 01Tell the two jobs apartExplain why stripping types is fast and file by file, and why only checking finds mistakes between files.
- 02Run TypeScript in NodeWrite code Node can strip: .ts import paths, type-only imports, and no syntax that makes new JavaScript.
- 03Check before you shipAdd tsc --noEmit to a build script and a CI workflow, so a type error stops the build.
Two jobs: checking and stripping
A shop changed its price formatting. formatPrice now needs a currency as well as a price:
export function formatPrice(price: number, currency: string) { return `${currency}${price.toFixed(2)}`;}Line 1 says formatPrice takes a price and a currency. Line 2 puts the currency in front of the price with two decimals, so formatPrice(7, "$") is $7.00. But one file still calls it the old way, and TypeScript marks it, on purpose here:
import { formatPrice } from "./format.ts"; console.log(formatPrice(7));Line 1 imports formatPrice from format.ts. Line 3 calls it with only a price. TypeScript says Expected 2 arguments, but got 1. To see that, it had to read both files: the call is in cart.ts, and the rule is in format.ts. Now run the project two ways:
$ node cart.ts
undefined7.00
$ npx tsc --noEmit
cart.ts(3,13): error TS2554: Expected 2 arguments, but got 1.Lines 1 and 2: Node runs cart.ts and prints undefined7.00, with no warning. The missing currency is undefined, and the template literal writes it as text. Lines 3 and 4: npx tsc --noEmit prints the error and writes no files. cart.ts(3,13) means "in cart.ts, line 3, column 13". This is tsc's plain form, the one it prints into a log. In your own terminal it adds colors and shows the line of code under the message.
These are two different jobs. Type checking reads your whole project and reports every type that doesn't fit. Type stripping removes the types from one file so it can run, and reports nothing. Node strips. So do Vite, esbuild, and the other fast tools from the last lesson. Only tsc, and your editor, check.
cart.ts calls formatPrice wrongly, and writes nothing. Stripping turns one file into JavaScript and runs it, mistake and all.A print shop prints whatever pages you send, quickly, one at a time. It doesn't read them. A proofreader is slower, but reads the whole book and notices when chapter 9 says something chapter 2 doesn't allow. A careful publisher uses both: fast printing while drafting, and a proofreader before the book goes out.
- In real life: The print shop prints each page as it comes, fast
- In TypeScript: Vite and Node strip each file and run it
- In real life: It prints a typo just as neatly as a correct word
- In TypeScript: Stripping runs code with type errors
- In real life: The proofreader reads the whole book and sees chapter 9 contradict chapter 2
- In TypeScript: tsc reads every file and sees a call that doesn't match its function
- In real life: The publisher won't ship until the proofreader signs off
- In TypeScript: CI runs
tsc --noEmitbefore anything ships
Checking and stripping are separate jobs. Stripping removes types from one file so it can run: fast, and blind to type errors. Checking reads every file and reports errors: only tsc and your editor do it. Most projects strip to run the code, and run tsc --noEmit to check it.
What stripping does to your code
To strip a file, a tool finds every piece of TypeScript syntax (annotations, type lines, interface blocks) and removes it. Nothing else changes. Tools differ in how they remove it. Compare Node and tsc on the same file:
type CartItem = { name: string; price: number }; function lineTotal(item: CartItem, quantity: number): number { return item.price * quantity;} const tea: CartItem = { name: "Tea", price: 3 };console.log(lineTotal(tea, 2));line 4 function lineTotal(item , quantity ) { return item.price * quantity;} const tea = { name: "Tea", price: 3 };console.log(lineTotal(tea, 2));Node replaces every type with spaces of the same length. Line 1, the type line, is now blank, and : CartItem on line 3 is a gap. Nothing moves, so return is still on line 4, at the same column. An error in this code points at the right place in cart.ts, with no extra file.
In cart.ts, line 1 is a type, line 3 declares lineTotal with three annotations, line 4 multiplies, and line 8 prints 6. With Node, every type became spaces of the same length: line 1 is blank, and item: CartItem became item and ten spaces. With tsc, the types are gone and the code is laid out again, so the return line moved from line 4 to line 2. Both print 6.
quantity stays on line 3 at the same column. tsc removes the types and the lines around them, so the same code moves to line 1, and a source map has to point back.Why does the position matter? When code throws an error, the message says which line and column it came from. With Node's blank spaces, that is the right place in your .ts file. With output that moved, tools write a source map: a .map file that links each position in the output back to your code, so your browser's developer tools can show cart.ts, line 4 (the Source maps in depth lesson shows how). tsc writes one when tsconfig.json has "sourceMap": true; Vite writes them for you while you work.
Stripping is fast because it never asks what a type means. It only finds where types are. That is also why it can't find a missing argument: knowing that formatPrice needs two arguments means reading format.ts, and understanding its types.
Bundlers strip each file and never check
Vite's own documentation says it plainly: Vite only removes types from .ts files and does not check them (see Vite's TypeScript notes). Since Vite 8, released in March 2026, it removes them with Oxc, a fast compiler written in Rust; Vite 7 and earlier did the same job with esbuild. Its dev server turns each file into JavaScript the moment the browser asks for it, one file at a time, so a change shows up almost instantly. Checking would mean reading every file again on every change. So the dev server serves code with type errors:
formatPrice(7). Vite only strips the types, so the page loads and shows undefined7.00. The two never talk to each other.While you work, your editor is the checker: it marks formatPrice(7) as you type. If you want every file checked as you save, run npx tsc --noEmit --watch in a second terminal. The --watch option keeps tsc running and checks again after each change. (A Vite plugin called vite-plugin-checker can also show the errors in the browser.)
vite build, which makes the files you put online, doesn't check either. The Vite starter's build script runs tsc first and joins the two with &&, which runs the second command only if the first one succeeds (its exit code, the number a command ends with, is 0). Try both scripts on both versions of cart.ts:
import { formatPrice } from "./format.ts"; console.log(formatPrice(7));vite buildwrites dist/undefined7.00vite build only strips types, so it builds dist/ from the code with the mistake. The page the shopper gets prints undefined7.00. Nothing in the build warned you.
With vite build alone, both versions build, and the broken page prints undefined7.00. With tsc --noEmit && vite build, tsc exits with code 2 for the broken version, and the build never starts. For the fixed version, tsc exits with 0, and Vite builds a page that prints $7.00.
Not every tool skips the check. The table below shows what common commands do with the same type error. Read each row as: the command, whether it checks types, and what happens.
| The command | Does it check types? | With a type error |
|---|---|---|
vite, the dev server | No. | It serves the page anyway. Your editor shows the error. |
vite build | No. | It builds dist/ anyway. |
next build | Yes, by default. | It stops the build and prints the error. |
node cart.ts | No. | It runs the code anyway. |
npx tsx cart.ts | No. | It runs the code anyway. |
npx tsc --noEmit | Yes, every file in the project. | It prints the error and exits with code 2. |
next build is the exception: Next.js runs a type check as part of its production build and stops on errors, unless its typescript.ignoreBuildErrors setting turns that off. tsx, a popular package for running .ts files in Node, strips with esbuild and never checks.
Node's built-in type stripping
Since Node 22.18 and 23.6, node cart.ts runs a TypeScript file directly (the Setting up a project lesson showed the first run). Node replaces types with spaces, as the playground above showed, and never checks them. To stay that simple, it follows a few rules:
- It ignores
tsconfig.json. Notarget, nopaths, no settings at all. - Imports name the real file, ending included:
"./types.ts", not"./types". - A type import says
type. Node strips one file at a time, so it can't know that a name from another file is only a type. - No syntax that makes new JavaScript, like
enum. - It won't strip
.tsfiles insidenode_modules, and it doesn't run.tsxfiles.
The third rule is the easiest to miss. checkout.ts imports a type and a function from types.ts, which exports CartItem and formatPrice. Pick how line 1 imports them:
import { CartItem, formatPrice } from "./types.ts"; const tea: CartItem = { name: "Tea", price: 3 };console.log(formatPrice(tea.price));node checkout.tsSyntaxError: The requested module './types.ts' does not provide an export named 'CartItem'1 error: line 1Node strips checkout.ts without reading types.ts. It sees a normal import of two names, so it asks types.ts for both, and CartItem isn't there: types are stripped from that file too. TypeScript, with verbatimModuleSyntax on, warns you first: 'CartItem' is a type and must be imported using a type-only import.
With a plain import, Node stops before running anything: does not provide an export named 'CartItem'. Types are stripped from types.ts too, so there is no CartItem to import. With type in front of the name, or import type on its own line, Node removes it and prints $3.00. The setting verbatimModuleSyntax makes TypeScript point at the plain import before Node does: 'CartItem' is a type and must be imported using a type-only import.
Node's documentation recommends these settings for code it strips. Each one makes TypeScript warn you about something Node would get wrong:
{
"compilerOptions": {
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true
}
}- Line 3,
noEmit: Node runs the.tsfiles, so tsc only checks. - Line 4,
target:esnext, because Node never rewrites syntax, and line 5,module:nodenext, so imports are checked Node's way. - Line 6,
rewriteRelativeImportExtensions, allows imports that end in.ts, and turns them into.jsif tsc ever does write JavaScript. - Line 7,
erasableSyntaxOnly, reports syntax that can't be stripped. Line 8,verbatimModuleSyntax, reports type imports withouttype.
Here is erasableSyntaxOnly at work on an enum, with the error on purpose:
enum Size { Small = "small", Large = "large",}console.log(Size.Large);Lines 1 to 4 make an enum of sizes, and line 5 prints large when tsc compiles it (the Enums lesson explains why it needs real code). TypeScript marks line 1: This syntax is not allowed when 'erasableSyntaxOnly' is enabled. Node stops at the same line:
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only modeThe table below lists the four kinds of syntax that Node's documentation says it can't strip. Read each row as: the code, what Node says, and what to write instead.
| When you write | Node says | Write instead |
|---|---|---|
enum Size { Small, Large } | TypeScript enum is not supported in strip-only mode | A const object with as const, or a union of strings. |
namespace Shop { export const name = "Tea"; } | TypeScript namespace declaration is not supported in strip-only mode | A module: put the code in its own file and export it. |
class Cart { constructor(private items: string[]) {} } | TypeScript parameter property is not supported in strip-only mode | Declare the field in the class body and set it in the constructor. |
import fs = require("fs"); | TypeScript import equals declaration is not supported in strip-only mode | import fs from "node:fs";, an ordinary import. |
Bundlers like Vite turn an enum into JavaScript themselves, so it works there; the limit is Node's. If you need the whole language in Node, npx tsx cart.ts runs any TypeScript, still without checking. An older package, ts-node, does check types by default, which makes it slower; its --transpileOnly option turns that off.
Checking in CI
Your editor checks the files you have open, and a teammate might forget to run npm run build. To make sure nothing unchecked ships, teams use CI, short for continuous integration: a service that runs your commands on a fresh computer every time someone pushes code. GitHub's is called GitHub Actions. Its instructions live in a workflow file in your repository:
name: Check
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- run: npm ci
- run: npx tsc --noEmit
- run: npm run build- Line 1 names the workflow. Line 2 runs it on every push and every pull request.
- Line 6 asks for a fresh Linux computer. Lines 8 to 11 copy your code onto it and install Node 22.
- Line 12,
npm ci, installs exactly the packages inpackage-lock.json. - Line 13 checks every file. Line 14 builds the page, but only if every step before it passed.
CI knows a step failed from its exit code. tsc exits with 0 when it finds no errors, and with 2 when it finds any. Push both versions of cart.ts:
failedInstalls the packages in package-lock.json.cart.ts(3,13): error TS2554: Expected 2 arguments, but got 1.
Error: Process completed with exit code 2.Skipped: an earlier step failed.Every push starts the workflow on a fresh computer. npm ci installs the packages. npx tsc --noEmit finds the missing argument and exits with code 2, so the step fails, the build step is skipped, and the run is marked failed. The pull request shows a red cross, and the broken code doesn't ship.
The log line is the same plain form you saw in the overview: file, then line and column in brackets, then the error number and message. tsc prints this form whenever its output goes to a log instead of a terminal, so CI logs stay easy to read and search.
npx tsc --noEmit exits with code 2, the run fails and the build never starts. Only code with no type errors reaches the build.Two options make checking faster on your own computer. --watch keeps tsc running and rechecks on every save. --incremental saves what tsc learned in a file called tsconfig.tsbuildinfo, so the next run only rechecks what changed.
A checkout project that checks before it ships
Here are the scripts of a Vite checkout project that uses everything in this lesson:
{
"scripts": {
"dev": "vite",
"check": "tsc --noEmit",
"check:watch": "tsc --noEmit --watch",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
}
}- Line 3,
npm run dev: Vite's dev server, fast, never checks. Your editor checks the files you have open. - Line 4,
npm run check: checks every file once. This is the command CI runs. - Line 5,
npm run check:watch: rechecks every file each time you save, in a second terminal. - Line 6,
npm run build: checks, and builds only if the check passed. - Line 7 serves the built page so you can look at it.
Every command in this lesson either stops on a type error or carries on. Sort them.
npm run dev(Vite) with a missing argumentvite buildwith a missing argumentnode cart.tswith a missing argumentnpx tsx cart.tswith a missing argumentnode sizes.tswith anenumin itnode checkout.tswithimport { CartItem, formatPrice }, whereCartItemis a typenpx tsc --noEmitwith a missing argumenttsc --noEmit && vite buildwith a missing argument
Sort each command by what it does with the mistake described.
Common mix-ups
- "Vite checks my types when it builds." No.
vite buildonly strips them. Puttsc --noEmit &&in front of it. - "The page loads in the dev server, so the code has no type errors." No. The dev server serves code with type errors. Look at your editor, or run
npm run check. - "
node cart.tsuses mytsconfig.json." No. Node ignores it completely: nopaths, notarget. - "Stripping changes what my code does." No. It only removes types. Stripped code does exactly what the same code without types would do.
- "
--noEmitmeans tsc can't fail." No. It writes no files, but it still exits with code2when it finds an error, and CI fails the step. - "tsx and ts-node are the same." No. tsx strips without checking. ts-node checks types by default, unless you pass
--transpileOnly.
The table puts the two jobs side by side. Read each row as a question, then each job's answer.
| Question | Checking | Stripping |
|---|---|---|
| How many files does it read? | Every file in the project, together. | One file at a time. |
| Does it report type errors? | Yes. That's its whole job. | No. It only removes types. |
| Does it read tsconfig.json? | Yes: strict, lib, module, and the rest. | Node doesn't. Vite reads a few settings, like verbatimModuleSyntax. |
| How fast is it? | Slower: it has to understand every type. | Fast: it only finds and removes types. |
| Who does it? | tsc, and your editor for the files you have open. | Vite, esbuild, SWC, Babel, tsx, and Node. |
Practice exercises
Some exercises are TypeScript code, checked the way the exercise says; others are a log, a script, or a workflow to read or fix. Try a guess first, then use the hints.
format.ts is the one from the overview: formatPrice(price, currency). TypeScript marks line 3. What does node cart.ts print? Then fix the call.
import { formatPrice } from "./format.ts"; console.log(formatPrice(4));import { formatPrice } from "./format.ts"; console.log(formatPrice(4, "$"));Node strips the types and runs the call anyway. currency is undefined, so the template makes undefined4.00. Passing the currency fixes the error and prints $4.00.
A CI run failed with this log. Which line of checkout.ts do you open?
Run npx tsc --noEmit
checkout.ts(4,25): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
Error: Process completed with exit code 2.checkout.ts(4,25): error TS2345: ...
The file is checkout.ts, the line is 4, and the column is 25.
Line 4 passes the text "2" where formatPrice wants a number.tsc writes each error as file(line,column), then the error number and message. Line 4 of checkout.ts passes the text "2" to formatPrice, which wants a number.
This project runs node checkout.ts and checks with verbatimModuleSyntax. TypeScript marks line 1, and Node would stop there. Fix the import. Which word do you add?
import { CartItem, formatPrice } from "./types.ts"; const cake: CartItem = { name: "Cake", price: 4 };console.log(formatPrice(cake.price));import { type CartItem, formatPrice } from "./types.ts"; const cake: CartItem = { name: "Cake", price: 4 };console.log(formatPrice(cake.price));type CartItem tells Node to strip the name, so it only imports formatPrice, and prints $4.00. verbatimModuleSyntax has nothing left to report.
This project's npm run build shipped a page with a type error. Change the build script so it can't happen again. What should the script be?
{
"scripts": {
"dev": "vite",
"build": "vite build"
}
}{
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build"
}
}tsc --noEmit checks every file and exits with code 2 on any error. && runs vite build only after exit code 0, so a type error stops the build.
Node can't strip this enum, and erasableSyntaxOnly marks it. Rewrite it so Node can run it and Size still works as a type. What does your code print?
enum Size { Small = "small", Large = "large",}console.log(Size.Large);const Size = { Small: "small", Large: "large",} as const;type Size = (typeof Size)[keyof typeof Size]; const chosen: Size = Size.Large;console.log(chosen);A const object is plain JavaScript, so stripping only removes as const and the type line. type Size is the union "small" | "large", so chosen can only hold one of them. It prints large.
This workflow builds the page but never checks types. Add a step. Which command does it run?
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- run: npm ci
- run: npm run buildjobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- run: npm ci
- run: npx tsc --noEmit
- run: npm run buildnpx tsc --noEmit exits with code 2 on any type error, so the run fails and the build step never starts. After npm ci, so tsc is installed; before the build, so nothing broken gets built.
Check your understanding
For each question, ask which tool is running, and whether it checks types or only strips them. The quiz shows its code without the checker, so the error list doesn't give the answers away.
Question 1 of 7
cart.tscalls a function fromformat.tswith one argument too few. Which command reports it?Choose an answer to see the explanation.
Question 2 of 7You run this file with
node cart.ts. What does it print?Read the code, then predict// format.ts exports formatPrice(price: number, currency: string) import { formatPrice } from "./format.ts"; console.log(formatPrice(5));Choose an answer to see the explanation.
Question 3 of 7What happens when Node runs this file?
Read the code, then predictTypeScriptenum Status { Paid, Pending, } console.log(Status.Paid);Choose an answer to see the explanation.
Question 4 of 7What does Node put where
: numberwas?Choose an answer to see the explanation.
Question 5 of 7Why does Node need
typeinimport { type CartItem, formatPrice } from "./types.ts"?Choose an answer to see the explanation.
Question 6 of 7In CI,
npx tsc --noEmitfinds one error. What happens to the run?Choose an answer to see the explanation.
Question 7 of 7Which build stops on a type error without any extra script?
Choose an answer to see the explanation.
Key takeaways
- Stripping removes types from one file so it can run. It's fast, and it never reports a type error.
- Checking reads every file together. Only tsc and your editor do it;
tsc --noEmitchecks without writing files. - Vite's dev server and
vite buildonly strip. Puttsc --noEmit &&in front of the build. - Node replaces types with spaces, ignores
tsconfig.json, and needs.tsimport paths andimport type. - Node can't strip
enum, namespaces with code, parameter properties, orimport =;erasableSyntaxOnlyflags them. - CI runs
tsc --noEmiton every push. Its exit code,2on any error, fails the run.
Remember the one-liner.
Tools strip types to run your code fast; tsc checks it, and CI makes sure that check passes before anything ships.
Coming next: Types in JavaScript files. You'll check plain .js files with JSDoc comments and checkJs, a first step towards TypeScript.