1.2 运行第一个 TypeScript 程序
本章解决什么问题:上一章讲清了一件事——
.ts文件必须先变回 JavaScript 才能被 Node.js 执行。本章就亲手把这件事做一遍:从零搭一个只有三个文件的最小 TypeScript 项目,用两种方式把它跑起来,并第一次体验「类型错误在程序运行之前就被抓住」。前置知识:1.1 JavaScript、TypeScript 与 Node.js,并且已按其中实践任务装好 Node.js(版本 ≥ 20)。
学习目标:读完本章后你能
- 从零创建一个最小 TypeScript 项目并成功运行;
- 说清
tsx是什么、它替你做了什么、它不做什么;- 区分「
tsc编译再用node运行」和「tsx直跑」两条路线,知道各自适合什么场合;- 读懂一条真实的
tsc类型错误信息;- 看懂
tsconfig.json里的strict、target、module三个字段。
建立直觉
回忆 1.1 的结论:Node.js 执行的是 JavaScript,.ts 文件里的类型标注必须先被「抹掉」。于是从源码到输出,摆在你面前的有两条路:
- 路线一:先编译,再运行。 用 TypeScript 官方编译器
tsc检查类型并产出一份纯 JavaScript 文件(比如dist/main.js),然后用node运行这份产物。两步,留下文件。 - 路线二:直跑。 用
tsx这个工具,一条命令直接运行.ts文件——它在内部飞快地抹掉类型标注、立即交给 Node.js 执行,不在硬盘上留下任何产物。一步,不留文件。
打个比方:tsc 像出版社的译者,交稿前会通读全文、校对每一处(类型检查),最后交付一份成品译稿(.js 文件);tsx 像同声传译,讲一句翻一句、快得几乎无感,但不做校对——口误也照翻不误。这个「不做校对」是本章的关键伏笔,下文会让你亲眼看到它的后果。
图 1.2-1 从 .ts 源码到终端输出的两条路线
阅读顺序:从左侧源码出发,任选一条路走到右侧输出。注意两条路的差别:上面这条会「检查类型」并留下 .js 文件;下面这条更快、不留文件,但跳过了类型检查。Pi 的开发脚本 pi-test.sh 走的就是下面这条 tsx 路线(本章末尾会看到源码)。
这张图值得记住的一点是:两条路线殊途同归——最终执行代码的都是 Node.js,输出也一模一样。差别只在「谁来抹类型、检查发生在哪一步、留不留中间文件」。本书所有实验采用的组合是:平时用 tsx 直跑(快),另用 tsc 的纯检查模式把关(严),下文都会演示。
最小示例:三个文件跑起来
现在动手。一个最小的 TypeScript 项目只需要三个文件。你可以自己新建一个目录照着敲,也可以直接使用本书仓库里已备好的实验目录 labs/typescript-basics/01-hello(推荐,本章实践任务就用它)。目录结构如下:
01-hello/
├── package.json 项目说明书:叫什么、怎么跑、需要哪些工具
├── tsconfig.json TypeScript 配置:类型检查有多严、产出什么版本的 JS
└── src/
└── main.ts 真正的程序第一个文件 package.json:
{
"name": "01-hello",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/main.ts",
"check": "tsc --noEmit"
},
"devDependencies": {
"tsx": "4.22.1",
"typescript": "5.9.3"
}
}这个文件是下一章(1.3)的主角,今天只需要看两处:"scripts" 里预先存了两条命令(start 和 check,一会儿就用到);"devDependencies" 声明了本项目开发时需要两个工具——tsx 和 typescript(后者提供 tsc)。其余字段先照抄。
第二个文件 tsconfig.json(每个字段的含义本章稍后专门讲):
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["src"]
}第三个文件 src/main.ts,真正的程序:
// greet 是一个带类型标注的函数:
// - name 必须是字符串(string)
// - year 必须是数字(number)
// - 返回值是字符串(string)
function greet(name: string, year: number): string {
return `你好,${name}!这是你在 ${year} 年运行的第一个 TypeScript 程序。`
}
console.log('Hello, TypeScript!')
console.log(greet('Pi 读者', 2026))逐段解释:greet 的参数和返回值都写了类型标注,语法和 1.1 见过的 add 一样。函数体里出现了一个新语法——模板字符串(Template String):用反引号 ` 而不是普通引号包住的字符串,内部可以写 ${表达式},运行时会被替换成表达式的值。它是 JavaScript 语法(不是 TypeScript 特有的),后面各章拼接文字都用它。
在项目目录下执行两条命令:
npm install
npm startnpm install 会按 devDependencies 的记录把 tsx 和 typescript 下载到本目录新出现的 node_modules 文件夹里(这条命令背后发生了什么,1.3 详讲);npm start 则执行 scripts 里存的 tsx src/main.ts。真实输出:
Hello, TypeScript!
你好,Pi 读者!这是你在 2026 年运行的第一个 TypeScript 程序。到这里,你已经走通了图 1.2-1 的下面那条路线:tsx 抹掉 main.ts 里的类型标注,Node.js 执行,输出两行文字,硬盘上没有多出任何 .js 文件。
第一次被类型检查拦下
1.1 里你「看」过一次类型报错,这次亲手触发它。实验目录里还有第二个程序 src/broken.ts,它是故意写错的:
// 一个故意写错的程序:把字符串传给了要求数字的参数
function double(n: number): number {
return n * 2
}
const result = double('你好')
console.log('double 的结果是:', result)double 要求参数是数字,调用处却传了字符串。先用 tsx 直跑它:
npx tsx src/broken.ts真实输出:
double 的结果是: NaN没有任何报错,程序「成功」跑完了,只是结果是个 NaN——Not a Number,JavaScript 用它表示「一次没有意义的数学运算的结果」:字符串 '你好' 乘以 2 算不出数字,就得到 NaN。这正是同声传译「口误照翻」的后果:tsx 根本不看类型标注,错误悄无声息地变成了一个坏数据。在几行的小程序里你一眼就能发现;在真实项目里,这个 NaN 可能被存进文件、传给下一个函数,等它终于引发可见的故障时,案发现场早就不在这里了。
现在换类型检查出场。package.json 里预存的 check 命令执行的是 tsc --noEmit——tsc 本职是编译器,加上 --noEmit(不产出)就变成纯检查器:只检查类型,不生成任何文件。运行:
npm run check真实输出:
src/broken.ts(6,23): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.逐段读懂这条报错:src/broken.ts(6,23) 是位置——第 6 行第 23 列,正是 '你好' 所在之处;TS2345 是错误编号(每类错误有固定编号,搜索引擎里查这个编号很有效);后半句的意思是「string 类型的实参不能赋给 number 类型的形参」。同时 tsc 以非零状态码退出——这让它可以充当自动化流程里的守门员:检查不过,后续步骤不执行。
把第 6 行的 '你好' 改成 21 再各跑一遍:npm run check 不再有任何报错,npx tsx src/broken.ts 输出 double 的结果是: 42。这一改一验,就是你和类型检查的第一次完整合作:错误在运行之前暴露、修完立刻确认,而不是等运行结果里冒出一个来路不明的 NaN。
tsc 编译:走完另一条路线
图 1.2-1 的上面那条路线也走一遍(先按上一节把 broken.ts 修好,否则编译会先被同一个类型错误拦下)。在项目目录执行:
npx tsc --outDir dist不带 --noEmit 的 tsc 就是完整编译:检查通过后,把 src 下每个 .ts 文件转换成对应的 .js,--outDir dist 指定产物放进 dist 目录。运行完打开 dist/main.js,真实内容(节选关键部分):
"use strict";
// greet 是一个带类型标注的函数:
// - name 必须是字符串(string)
// - year 必须是数字(number)
// - 返回值是字符串(string)
function greet(name, year) {
return `你好,${name}!这是你在 ${year} 年运行的第一个 TypeScript 程序。`;
}
console.log('Hello, TypeScript!');
console.log(greet('Pi 读者', 2026));对照 src/main.ts 看,1.1 讲的「编译就是抹掉类型」此刻具体可见:name: string、: number 等标注全部消失,函数逻辑、注释原样保留(开头多出的 "use strict" 是一行严格模式标记,由编译配置自动加上,先不必深究)。这份文件里已经没有任何 TypeScript 痕迹,可以直接交给 Node.js:
node dist/main.js真实输出与 npm start 完全一致:
Hello, TypeScript!
你好,Pi 读者!这是你在 2026 年运行的第一个 TypeScript 程序。两条路线都走过了,总结成一张表:
tsc 编译 + node 运行 | tsx 直跑 | |
|---|---|---|
| 步骤 | 两步 | 一步 |
| 类型检查 | 有,不过关就报错 | 完全没有 |
| 硬盘产物 | .js 文件 | 无 |
| 典型场合 | 发布 package、构建产物 | 日常开发、跑脚本 |
实际工程里两者是分工而非二选一:开发时用 tsx 追求「改完即跑」,类型把关交给 tsc --noEmit,正式发布时才用完整编译产出 .js。本书的实验全部用「tsx 跑 + tsc --noEmit 查」这套组合;Pi 的开发流程也是同样的思路(见下文源码)。
tsconfig.json 初见
最后回头看那个照抄的 tsconfig.json。它是 TypeScript 工具链的统一配置文件:tsc 靠它决定检查多严、产出什么样的 JavaScript;tsx 和编辑器(比如 VS Code 的报错红线)也读取它。本章只需要看懂三个字段:
strict: true——一键打开全部严格检查。TypeScript 出于历史兼容,默认检查是「宽松档」,不少隐患默认放行(比如允许变量可能是空值而不提醒,具体类别 2.1 起会陆续遇到)。没有它,类型检查形同虚设一半。Pi 全仓库开着 strict(1.1 引用过tsconfig.base.json),本书所有实验保持一致。target: "ES2022"——编译产物使用哪个年份版本的 JavaScript 语法(ES2022 即 ECMAScript 2022 标准,1.1 介绍过这个命名方式)。如果 target 定得很老,tsc会把新语法翻译成老写法以兼容旧运行时;我们的运行时是新版 Node.js,直接用 ES2022 即可。module: "ESNext"——产物采用哪种「模块语法」,即文件之间import/export互相引用的写法。模块系统是 1.4 章的主角,这里先记住:ESNext表示保留最新的 import/export 原样输出,和package.json里的"type": "module"是一对配套声明。
剩下的 moduleResolution: "Bundler" 是与 module 配套的「按什么规则查找被引用文件」的选项,1.4 再讲;include 指明只处理 src 目录。TypeScript 的配置项总数上百,其余的现在一概不用管——本书遇到一个讲一个。
Pi 中哪里用到了它
本章的两位主角 tsx 和 tsconfig.json,在 Pi 仓库根目录就同框出现过。Pi 仓库根有一个开发脚本 pi-test.sh,供开发者不经打包构建、直接从 TypeScript 源码把整个 Pi 跑起来,它的最后一行是:
tsx把这一行和你刚跑过的 npx tsx src/main.ts 对照:同一个工具、同一个动作,只是入口文件从 12 行的 main.ts 换成了牵动几百个源文件的 cli.ts——「改完源码立刻能跑」这件事,从 hello 级项目到 Pi 这个规模,靠的是同一条 tsx 路线。
tsx 本身则记录在 Pi 根 package.json 的 devDependencies 里:
tsx至于类型把关,1.1 已经看到 Pi 的 tsconfig.base.json 全仓库开启 strict;Pi 的日常检查命令同样采用「只检查、不产出」的思路做全仓库类型检查,具体在第四部分读仓库工程化时再展开。
实践任务
labs/typescript-basics/01-hello目标:在实验目录 labs/typescript-basics/01-hello 中亲手走完本章全部三条体验:tsx 直跑、类型检查拦错、tsc 编译。
步骤:
进入实验目录,安装并运行:
shcd labs/typescript-basics/01-hello npm install npm start直跑那个故意写错的程序,观察它「不报错但结果是坏的」:
shnpx tsx src/broken.ts做类型检查,让错误在运行前现形:
shnpm run check修好它:把
src/broken.ts第 6 行的double('你好')改成double(21),保存后重复第 2、3 步。走编译路线:
npx tsc --outDir dist,打开dist/main.js与src/main.ts对照,找出「消失了什么」,最后node dist/main.js验证输出。
预期现象:第 1 步输出与 expected-output.txt 一致(两行问候);第 2 步输出 double 的结果是: NaN;第 3 步报出 src/broken.ts(6,23): error TS2345 ...;第 4 步之后 check 无报错、broken 输出 double 的结果是: 42;第 5 步 dist/main.js 里所有类型标注消失,运行输出与第 1 步完全相同。
如何判断成功:README 末尾「完成标准」四项全部勾上。
常见错误:
- 报「找不到 tsx」之类的错误:多半没执行
npm install,或不在实验目录里。确认目录下出现了node_modules再试。 node/npm本身找不到:回到 1.1 的实践任务先装好 Node.js。- 第 4 步修改后 check 仍报错:确认文件已保存,且改的是调用处
double('你好'),不是把函数签名的number改成了string——后者是「迁就错误」,会让n * 2在下一步暴露新问题。 - 第 5 步编译报错:说明
broken.ts还没修好,tsc编译前会先做与 check 相同的类型检查。
对应源码位置:Pi 用同样的方式直跑源码,见 pi-test.sh 最后一行(本章「Pi 中哪里用到了它」)。
本章小结
- 从
.ts到终端输出有两条路线:tsc编译产出.js后用node运行(有检查、留产物),或tsx一步直跑(快,但完全不检查类型)。 tsx是「转换 + 执行」合一的工具;tsc --noEmit是「只检查、不产出」的纯类型把关。日常开发用前者跑、后者查,Pi 与本书实验都是这套组合。- 类型错误的第一现场体验:
tsx直跑坏代码得到无声的NaN,tsc在运行前给出「文件(行,列) + 错误编号 + 原因」的报错并以非零状态码退出。 tsconfig.json是 TypeScript 工具链的统一配置,目前只需看懂strict(严格检查全开)、target(产物的 JS 版本)、module(模块语法,1.4 展开),其余遇到再学。- 最小 TypeScript 项目 =
package.json+tsconfig.json+src/*.ts三件套。
关键术语:tsx、npx、编译器 tsc、--noEmit、tsconfig.json(strict / target / module)、模板字符串(Template String)、NaN
关键源码索引:pi-test.sh 最后一行(tsx 直跑 Pi 入口);package.json devDependencies 中固定版本的 tsx
自测问题:
tsx直跑和tsc编译后运行,最终执行代码的分别是谁?两条路线的输出为什么完全一样?npx tsx src/broken.ts为什么不报错反而输出 NaN?同一个文件tsc --noEmit为什么能报错?tsc和tsc --noEmit的行为差别是什么?各自适合放在工作流的哪个环节?- Pi 的
pi-test.sh用什么工具启动源码里的 Pi?这和你本章敲过的哪条命令是同一件事?
下一章预告:1.3 npm、package.json 与项目结构——本章两次「先照抄」的 package.json 是下一章的主角:npm install 那几秒钟里发生了什么、node_modules 里装的是什么、dependencies 和 devDependencies 差在哪,以及一个像 Pi 那样的多 package 仓库是怎么组织的。