Topic 15.1
TypeScript with Vite, Next.js and Node
In one line
A build tool turns your files into something a browser can run, and it usually does not check types. Type checking (tsc --noEmit) is a separate job that you must run yourself, and each framework wires it up in its own way.
Think of it like this
A school has a proofreader and a printer. The printer takes your essay and makes copies, quickly, and doesn't read it. The proofreader reads every sentence and circles mistakes, but makes no copies. If you only use the printer, you get fast copies with typos. If you only use the proofreader, you have a clean essay and nothing to hand out. Real projects need both. TypeScript's checker is the proofreader. Vite, Next.js and Node are printers.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Bundler
- A tool that joins your many files into a few files a browser can load, and shrinks them. Vite is one.
- Build
- Turning source code into the files that actually run (JavaScript, CSS, HTML).
- Type check
- Reading the code to find type errors, without running it and, with --noEmit, without writing any file.
- --noEmit
- A tsc flag meaning "check, but don't write JavaScript files".
- Transpile
- Rewrite code from one syntax to another. For TypeScript to JavaScript this mostly means erasing the types.
- tsc -b
- Build mode: build the projects listed in tsconfig
references, in the right order, skipping what is up to date. - isolatedModules
- A flag that makes the checker reject code a tool can't convert by looking at one file alone.
- next-env.d.ts
- A file Next.js generates in your project so TypeScript knows about images, CSS imports and Next's types.
- Type stripping
- Node running a .ts file by erasing the types and running what is left, without checking them.
- typedRoutes
- A Next.js option that generates a type of all your pages, so links with typos fail to compile.
Step by step
01Two jobs, two commands
Open the package.json that npm create vite@latest writes for the React + TypeScript template (create-vite 9.2.1). The important line is build: it runs the checker first and the bundler second.
If tsc -b finds an error it exits with a failure code, and && means vite build never starts. In a CI pipeline (CI means continuous integration: a server that runs your checks automatically on every change) this is what turns a type error into a red build.
{
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}02Why the template has three tsconfig files
The template's tsconfig.json has "files": [] and two references. The code that runs in the browser (tsconfig.app.json, with lib: ["ES2023", "DOM"] and types: ["vite/client"]) and the code that runs in Node (tsconfig.node.json, which only includes vite.config.ts, with types: ["node"]) need different settings. A browser has document; Node has process. Splitting them stops you from using document in the Vite config by accident.
Notice what is missing: no strict. TypeScript 6.0 made strict: true the default (Topic 0.3), so the template doesn't need to write it.
03See the difference with your own eyes
A scratch project with Vite 8.3.4 and TypeScript 7.0.2, and one file that passes a string to a function that takes a number. Vite builds it happily. tsc --noEmit refuses.
Read the output below: the build says built, and the shipped JavaScript still passes the text 100 to the function (the minified file ends with a call that has the quoted 100 inside), the very bug TypeScript predicted. The proofreader and the printer disagree because only one of them reads.
$ npx vite build
vite v8.3.4 building client environment for production...
transforming...
✓ 4 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.11 kB │ gzip: 0.11 kB
dist/assets/index-BRAizrDa.js 0.72 kB │ gzip: 0.41 kB
✓ built in 146ms
$ npx tsc --noEmit -p .
src/main.ts(4,20): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
$ echo $?
104Isolated files: why import type exists
A bundler turns each file into JavaScript without opening the files it imports. Look at export { User }: if User is an interface it must disappear; if it is a function it must stay. From one file alone the tool can't be sure.
With verbatimModuleSyntax on (a default in tsc --init and in the editor on this site), the checker makes you say which it is: export type { User }, or import type { User } from "./user". You will see the exact error in the second live example.
05Next.js: the framework does both jobs
next build compiles and type-checks. The Next.js docs shipped in this repo (Next.js 16.3.6) say that next build runs the project-local tsc by default, and that installing typescript@^7 is enough to use TypeScript 7. The setting that switches back to the compiler API is experimental.useTypeScriptCli: false, marked experimental.
Two generated files matter. next-env.d.ts is rewritten by next dev, next build and next typegen: leave it alone and don't commit it. With typedRoutes: true in the Next config, Next writes link types into .next/types, so tsconfig.json must include .next/types/**/*.ts; then a literal href="/aboot" is an error and a computed string needs a cast to Route.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
typedRoutes: true,
};
export default nextConfig;06Node runs .ts files by erasing types
Node 24.11.0 (checked on this machine, where process.features.typescript is "strip") runs node hi.ts directly. It removes the types and runs the rest, with no checking. The Next.js docs say type stripping is on by default from Node v22.18.0.
Syntax that needs generated code is refused. That is exactly the set that erasableSyntaxOnly makes tsc report, so turn that flag on in a Node project and the editor warns you first.
$ node hi.ts
118
$ node -p "process.features.typescript"
strip
$ node en.ts # a file containing: enum Color { Red, Green }
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only modeTry it yourself
- 1
Fix the shipped bug
In the first example, change
"10"to10. Predict the console output before you look: it should be30.Now put the string back and change
total(fromApi)tototal(fromApi as Item[]). The error disappears and the console still prints01020. A cast silences the proofreader without fixing the text. This is why Topic 13.4 teaches checking data where it enters. - 2
Teach the router a new page
In the third example, add
"/contact"to theRouteunion and change the last call tonavigate("/contact"). The error is gone. This is what Next.js does for you every time you add a file to theappfolder.Then remove the
as Routecast on thefromUserline and read the new error. It is the same kind of error as/aboot: a plainstringis not aRoute. - 3
Make the config honest
In the fourth example, add a second variable
DEBUG: "true"toenvand writereadFlag(e: Env, name: string): booleanthat returnstrueonly when the text is exactly"true". Why isBoolean("false")the wrong tool?
Code & diagrams
The Types panel is the proofreader (tsc --noEmit). The Console is the printer's output: the types were erased and the code ran, giving the text "01020" instead of 30. A bundler would have shipped exactly this. Change "10" to 10.
Console
(nothing was printed)Expected console output
01020This editor uses verbatimModuleSyntax, like tsc --init. A tool that reads one file can't tell if User is a type (erase it) or a value (keep it), so you must say which. Change the last line to export type { User };.
Console
(nothing was printed)Expected console output
AliceNext.js generates a type like Route from your app folder when typedRoutes is on. A template literal type accepts every /blog/ page. A computed string needs a cast (as Route), which is you promising the compiler it is a real page. The typo is rejected, yet the console still prints it: types do not run.
Console
(nothing was printed)Expected console output
going to /about
going to /blog/hello-ts
going to /blog/hello-ts
going to /pricing
going to /abootprocess.env and import.meta.env look the same way: a name maps to text or undefined. readPort is the one place that checks and converts, so the rest of the program receives a real number.
Console
(nothing was printed)Expected console output
3001
string undefinedBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget `type` in the re-export
In the second example, the last line is export { User };. Leave it as is and read the Types panel. Then change it to export type { User };.
Break #2
Typo a page
In the third example, the last call is navigate("/aboot"). Leave it.
Break #3
Check and build disagree
In a project with Vite, put a type error in src/main.ts, then run npx vite build and npx tsc --noEmit -p ..
Myth vs fact
Myth
If vite build or npm run dev works, my types are fine.
Fact
Vite never checks types. A broken type annotation builds and runs. Your editor and tsc --noEmit are what check.
Myth
node file.ts type-checks my code.
Fact
Node only erases types, then runs. It also rejects syntax that needs generated code, like enum, in its default mode.
Myth
The as Route cast makes the link safe.
Fact
A cast is a promise, not a check. It stops the error; it doesn't make a wrong string right. Use it only where you know the value is a real page.
Myth
next-env.d.ts is my file to edit and commit.
Fact
It is regenerated by next dev, next build and next typegen. The Next.js docs say not to edit it and to keep it out of Git.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The template pins
typescript: ~6.0.2(create-vite 9.2.1). You can move a Vite project to TypeScript 7.0.2:tsc -bworks the same and is much faster. The catch is any tool that imports thetypescriptpackage as a library, such as typescript-eslint. Topic 15.2 shows the fix. - ▸
tsc -bis incremental: it writes.tsbuildinfofiles and skips projects whose inputs have not changed. WithoutnoEmitit also writes.d.tsoutput for referenced projects (Topic 15.6). Topic 12.4 covers project references in depth. The template turns emit off and usestsBuildInfoFileundernode_modules/.tmp. - ▸
Vite takes
import.meta.envtypes fromvite/client(that is thetypes: ["vite/client"]line). Under TypeScript 6 and 7 the default istypes: [], so a global likeprocess,describeorimport.meta.envis not typed until you list its package intypes. - ▸
Type stripping erases types by replacing them with spaces, so the line and column numbers in stack traces still point at your source. Topic 12.5 compares all the ways to run TypeScript. It is also why
erasableSyntaxOnlybansenum,namespacewith code, constructor parameter properties andimport x = require(): they all need generated JavaScript.
Remember this
- 1
The problem this solves: when you press "run" or "build", it is easy to believe that TypeScript has checked your code. Usually it has not. The tools that run and build your project are separate programs from the type checker, and an app can ship with type errors that nobody saw. This topic shows which tool does which job, so you know what to run and when.
- 2
Recall before you start. Topic 0.2: the compiler checks, then erases the types, and the types do not exist at runtime. Topic 1.7: a value that may be missing has the type
string | undefined. Topic 9.4: a template literal type describes a pattern of text, such as/blog/${string}. Topic 11.1:import typeandexport typeare erased completely. - 3
A build tool (a *bundler*) takes your source files and produces what the browser runs: it joins files, shrinks them and removes the types. Vite is a popular one. When it meets a
.tsfile it only erases the types, file by file, and never asks whether the types are correct. That is why it is fast. It also meansvite buildcan succeed on code thattscwould reject: the first live example below does exactly that. - 4
So a TypeScript project has two jobs that must both pass: the *check* (
tsc --noEmit: read everything, report type errors, write no files) and the *build* (vite build,next build: produce the output).--noEmitmeans "don't write JavaScript": the build tool already does that. Project templates put both into one script, for exampletsc -b && vite build, so the build stops at the first type error. - 5
Because a build tool looks at one file at a time, some TypeScript features can't work: it can't know whether
export { User }(a *re-export*: passing a name on to other files) exports a type (which must vanish) or a value (which must stay) without reading the other file. The optionsisolatedModulesandverbatimModuleSyntaxmake the checker forbid the unclear cases, andimport type/export typemake your intent explicit. The second live example shows the error. This is the same reasonconst enumand a few other features are discouraged in bundled projects. - 6
Next.js is a framework that includes its own build step.
next buildalso type-checks. In the Next.js version on this site (16.3.6) the docs say Next.js uses the project-localtsccommand by default, which works with TypeScript 7, because TypeScript 7 has no JavaScript compiler API (Topic 15.2 explains why that matters). Next.js also writesnext-env.d.ts(type references for images, CSS imports and Next's own types: do not edit it, and keep it out of Git), and withtypedRoutes: trueit generates a type of every real page so a typo in a link becomes a compile error. The third live example builds that idea by hand. - 7
Node.js can now run
.tsfiles directly by *stripping* the types (erasing them, nothing more). It works on plain syntax but refuses syntax that generates code, such asenum. TypeScript 5.8'serasableSyntaxOnlyflag makestscreport those same constructs, so the editor warns you before Node does. Node does not check types either: it is a printer too. - 8
Environment variables are a classic place where types lie. Whatever a template says,
process.env.PORTand Vite'simport.meta.env.VITE_API_URLare text or missing, never a number. Declare the shape you expect once, read the values in one place, convert and validate them there (the fourth example), and the rest of your code gets honest types.
Explain it without notes
Why can vite build succeed on code that tsc rejects, and what do you do about it?
What does --noEmit do, and why do project templates use tsc -b && vite build?
What do isolatedModules and verbatimModuleSyntax protect against?
What do next-env.d.ts and typedRoutes do in a Next.js project?
Node can run .ts files. Does that replace tsc?
Practice
A teammate says "the build is green, so the types are fine". The project uses Vite. Write the two package.json scripts that make the claim true, and say what each command is responsible for.
Write a Route type for a shop with the pages /, /cart, /checkout and every product page /products/<id>, then a goTo(route: Route) function. Show one call that works and one that fails.
Environment variables arrive as text. Write parseBool(value: string | undefined, fallback: boolean): boolean that accepts "true" and "false" only, and returns fallback for undefined. What should it do for "yes"?
Trade-offs
- ↔
Running
tscbefore every build adds time. With TypeScript 7 (native, about ten times faster than 6.0) this cost is small; in older setups people run the check only in CI and rely on the editor locally. Either way, the check must exist somewhere that blocks a merge. - ↔
Strict per-file rules (
verbatimModuleSyntax,erasableSyntaxOnly) make code a little more verbose (import type, noenum) in exchange for working with every converter, Node's strip mode included.
Done when you can
I can say which tool checks types and which only erases them.
I can explain why
tsc --noEmitandvite buildare two separate commands.I can explain why
export type/import typeexist for bundlers.I know what
next-env.d.tsandtypedRoutesare for.I know that Node's
.tssupport strips types and does not check them.I read environment variables as
string | undefinedand convert them in one place.