Command Palette

Search for a command to run...

Hectal
PHASE 15Intermediate ~31 min· topic 1 of 6

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.

package.json (create-vite 9.2.1, template react-ts)whole filejson
{
  "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.

Why the template has three tsconfig filesdiagram
Rendering diagram…

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.

terminal (scratch project, src/main.ts calls addTax("100"))whole filebash
$ 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 $?
1

04Isolated 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.

next.config.tswhole filets
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.

terminal (Node v24.11.0)whole filebash
$ 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 mode

Try it yourself

  1. 1

    Fix the shipped bug

    In the first example, change "10" to 10. Predict the console output before you look: it should be 30.

    Now put the string back and change total(fromApi) to total(fromApi as Item[]). The error disappears and the console still prints 01020. A cast silences the proofreader without fixing the text. This is why Topic 13.4 teaches checking data where it enters.

  2. 2

    Teach the router a new page

    In the third example, add "/contact" to the Route union and change the last call to navigate("/contact"). The error is gone. This is what Next.js does for you every time you add a file to the app folder.

    Then remove the as Route cast on the fromUser line and read the new error. It is the same kind of error as /aboot: a plain string is not a Route.

  3. 3

    Make the config honest

    In the fourth example, add a second variable DEBUG: "true" to env and write readFlag(e: Env, name: string): boolean that returns true only when the text is exactly "true". Why is Boolean("false") the wrong tool?

Code & diagrams

The build tool does not check: TypeScript warned, the page runs anyway

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

01020
Why bundlers need `export type`

This 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

Alice
typedRoutes by hand: the set of pages becomes a type

Next.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 /aboot
Environment variables are text or missing

process.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 undefined
Vite or Next.js? Which tool does the checkingdiagram
Rendering diagram…

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

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 };.

terminal
$ Types panel
── what you'll see ──
index.ts(9,10): error TS1205: Re-exporting a type when 'verbatimModuleSyntax' is enabled requires using 'export type'.

Break #2

Typo a page

In the third example, the last call is navigate("/aboot"). Leave it.

terminal
$ Types panel
── what you'll see ──
index.ts(15,10): error TS2345: Argument of type '"/aboot"' is not assignable to parameter of type 'Route'.

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 ..

terminal
$ npx tsc --noEmit -p .
── what you'll see ──
src/main.ts(4,20): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

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 -b works the same and is much faster. The catch is any tool that imports the typescript package as a library, such as typescript-eslint. Topic 15.2 shows the fix.

  • ▸

    tsc -b is incremental: it writes .tsbuildinfo files and skips projects whose inputs have not changed. Without noEmit it also writes .d.ts output for referenced projects (Topic 15.6). Topic 12.4 covers project references in depth. The template turns emit off and uses tsBuildInfoFile under node_modules/.tmp.

  • ▸

    Vite takes import.meta.env types from vite/client (that is the types: ["vite/client"] line). Under TypeScript 6 and 7 the default is types: [], so a global like process, describe or import.meta.env is not typed until you list its package in types.

  • ▸

    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 erasableSyntaxOnly bans enum, namespace with code, constructor parameter properties and import x = require(): they all need generated JavaScript.

Remember this

  1. 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. 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 type and export type are erased completely.

  3. 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 .ts file it only erases the types, file by file, and never asks whether the types are correct. That is why it is fast. It also means vite build can succeed on code that tsc would reject: the first live example below does exactly that.

  4. 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). --noEmit means "don't write JavaScript": the build tool already does that. Project templates put both into one script, for example tsc -b && vite build, so the build stops at the first type error.

  5. 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 options isolatedModules and verbatimModuleSyntax make the checker forbid the unclear cases, and import type / export type make your intent explicit. The second live example shows the error. This is the same reason const enum and a few other features are discouraged in bundled projects.

  6. 6

    Next.js is a framework that includes its own build step. next build also type-checks. In the Next.js version on this site (16.3.6) the docs say Next.js uses the project-local tsc command 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 writes next-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 with typedRoutes: true it 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. 7

    Node.js can now run .ts files directly by *stripping* the types (erasing them, nothing more). It works on plain syntax but refuses syntax that generates code, such as enum. TypeScript 5.8's erasableSyntaxOnly flag makes tsc report those same constructs, so the editor warns you before Node does. Node does not check types either: it is a printer too.

  8. 8

    Environment variables are a classic place where types lie. Whatever a template says, process.env.PORT and Vite's import.meta.env.VITE_API_URL are 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

01

Why can vite build succeed on code that tsc rejects, and what do you do about it?

02

What does --noEmit do, and why do project templates use tsc -b && vite build?

03

What do isolatedModules and verbatimModuleSyntax protect against?

04

What do next-env.d.ts and typedRoutes do in a Next.js project?

05

Node can run .ts files. Does that replace tsc?

Practice

01

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.

02

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.

03

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 tsc before 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, no enum) 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 --noEmit and vite build are two separate commands.

  • I can explain why export type / import type exist for bundlers.

  • I know what next-env.d.ts and typedRoutes are for.

  • I know that Node's .ts support strips types and does not check them.

  • I read environment variables as string | undefined and convert them in one place.