Skip to content

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 里的 stricttargetmodule 三个字段。

建立直觉

回忆 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

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" 里预先存了两条命令(startcheck,一会儿就用到);"devDependencies" 声明了本项目开发时需要两个工具——tsxtypescript(后者提供 tsc)。其余字段先照抄。

第二个文件 tsconfig.json(每个字段的含义本章稍后专门讲):

json
{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler"
  },
  "include": ["src"]
}

第三个文件 src/main.ts,真正的程序:

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 特有的),后面各章拼接文字都用它。

在项目目录下执行两条命令:

sh
npm install
npm start

npm install 会按 devDependencies 的记录把 tsxtypescript 下载到本目录新出现的 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 文件。

📘 概念tsx(tsx)
tsx 是一个把「转换 .ts + 用 Node.js 执行」合成一步的命令行工具(工具名,不是缩写)。没有它,每次改完代码都得先编译出 .js 再运行、两步走;有了它,`tsx src/main.ts` 一步到位,改代码到看结果的循环快得多。代价是它为了速度**完全跳过类型检查**——类型把关必须交给别的命令(见下文)。
🌱 初学者提示npx 是什么?和 npm start 什么关系?
装进 node_modules 的命令行工具不能直接敲名字使用,npm 提供了两条路:`npx 工具名 参数`(比如 `npx tsx src/main.ts`)会到当前项目的 node_modules 里找到这个工具并执行;而 `npm start` / `npm run check` 是执行 package.json 的 scripts 里**预先存好**的命令。两条路最终跑的是同一个工具。本章下文两种写法都会出现。
🌱 初学者提示别把 tsx 工具和 .tsx 文件后缀搞混
前端框架 React 的世界里有一种后缀为 .tsx 的文件(带界面模板的 TypeScript)。它和本章的 tsx 工具只是重名,毫无关系。本书不涉及 React,见到 tsx 一律指这个运行工具。

第一次被类型检查拦下

1.1 里你「看」过一次类型报错,这次亲手触发它。实验目录里还有第二个程序 src/broken.ts,它是故意写错的

ts
// 一个故意写错的程序:把字符串传给了要求数字的参数
function double(n: number): number {
  return n * 2
}

const result = double('你好')
console.log('double 的结果是:', result)

double 要求参数是数字,调用处却传了字符串。先用 tsx 直跑它:

sh
npx tsx src/broken.ts

真实输出:

double 的结果是: NaN

没有任何报错,程序「成功」跑完了,只是结果是个 NaN——Not a Number,JavaScript 用它表示「一次没有意义的数学运算的结果」:字符串 '你好' 乘以 2 算不出数字,就得到 NaN。这正是同声传译「口误照翻」的后果:tsx 根本不看类型标注,错误悄无声息地变成了一个坏数据。在几行的小程序里你一眼就能发现;在真实项目里,这个 NaN 可能被存进文件、传给下一个函数,等它终于引发可见的故障时,案发现场早就不在这里了。

现在换类型检查出场。package.json 里预存的 check 命令执行的是 tsc --noEmit——tsc 本职是编译器,加上 --noEmit(不产出)就变成纯检查器:只检查类型,不生成任何文件。运行:

sh
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。

⚠️ 常见误解以为 tsx 会替你检查类型
tsx 直跑永远不会报类型错——它只抹类型、不看类型,代码写得再离谱它也照跑。所以「tsx 跑通了」不等于「类型没问题」。正确的工作流是两条命令搭配:改完代码用 tsx 跑效果,用 tsc --noEmit 做类型把关;把关命令值得存进 scripts(本例的 npm run check),顺手到没有借口不跑。

tsc 编译:走完另一条路线

图 1.2-1 的上面那条路线也走一遍(先按上一节把 broken.ts 修好,否则编译会先被同一个类型错误拦下)。在项目目录执行:

sh
npx tsc --outDir dist

不带 --noEmittsc 就是完整编译:检查通过后,把 src 下每个 .ts 文件转换成对应的 .js--outDir dist 指定产物放进 dist 目录。运行完打开 dist/main.js,真实内容(节选关键部分):

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:

sh
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 中哪里用到了它

本章的两位主角 tsxtsconfig.json,在 Pi 仓库根目录就同框出现过。Pi 仓库根有一个开发脚本 pi-test.sh,供开发者不经打包构建、直接从 TypeScript 源码把整个 Pi 跑起来,它的最后一行是:

pi-test.sh · tsx
earendil-works/pi@c13ffe1第 57 行在 GitHub 查看 ↗
用仓库自带的 tsx(node_modules/.bin/tsx),按 --tsconfig 指定的配置,直接运行 Pi 的入口源文件 packages/coding-agent/src/cli.ts;行尾是把脚本收到的参数原样转交给 Pi。

把这一行和你刚跑过的 npx tsx src/main.ts 对照:同一个工具、同一个动作,只是入口文件从 12 行的 main.ts 换成了牵动几百个源文件的 cli.ts——「改完源码立刻能跑」这件事,从 hello 级项目到 Pi 这个规模,靠的是同一条 tsx 路线。

tsx 本身则记录在 Pi 根 package.jsondevDependencies 里:

earendil-works/pi@c13ffe1第 60 行在 GitHub 查看 ↗
Pi 把 tsx 固定在 4.22.1 版本——与本书实验用的版本一致,你在实验里看到的行为就是 Pi 开发者日常看到的行为。

至于类型把关,1.1 已经看到 Pi 的 tsconfig.base.json 全仓库开启 strict;Pi 的日常检查命令同样采用「只检查、不产出」的思路做全仓库类型检查,具体在第四部分读仓库工程化时再展开。

实践任务

🛠 实践任务跑通、弄坏、修好:完整体验一个最小 TypeScript 项目labs/typescript-basics/01-hello

目标:在实验目录 labs/typescript-basics/01-hello 中亲手走完本章全部三条体验:tsx 直跑、类型检查拦错、tsc 编译。

步骤

  1. 进入实验目录,安装并运行:

    sh
    cd labs/typescript-basics/01-hello
    npm install
    npm start
  2. 直跑那个故意写错的程序,观察它「不报错但结果是坏的」:

    sh
    npx tsx src/broken.ts
  3. 做类型检查,让错误在运行前现形:

    sh
    npm run check
  4. 修好它:把 src/broken.ts 第 6 行的 double('你好') 改成 double(21),保存后重复第 2、3 步。

  5. 走编译路线:npx tsc --outDir dist,打开 dist/main.jssrc/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 直跑坏代码得到无声的 NaNtsc 在运行前给出「文件(行,列) + 错误编号 + 原因」的报错并以非零状态码退出。
  • tsconfig.json 是 TypeScript 工具链的统一配置,目前只需看懂 strict(严格检查全开)、target(产物的 JS 版本)、module(模块语法,1.4 展开),其余遇到再学。
  • 最小 TypeScript 项目 = package.json + tsconfig.json + src/*.ts 三件套。

关键术语:tsx、npx、编译器 tsc--noEmittsconfig.json(strict / target / module)、模板字符串(Template String)、NaN

关键源码索引pi-test.sh 最后一行(tsx 直跑 Pi 入口);package.json devDependencies 中固定版本的 tsx

自测问题

  1. tsx 直跑和 tsc 编译后运行,最终执行代码的分别是谁?两条路线的输出为什么完全一样?
  2. npx tsx src/broken.ts 为什么不报错反而输出 NaN?同一个文件 tsc --noEmit 为什么能报错?
  3. tsctsc --noEmit 的行为差别是什么?各自适合放在工作流的哪个环节?
  4. Pi 的 pi-test.sh 用什么工具启动源码里的 Pi?这和你本章敲过的哪条命令是同一件事?

下一章预告1.3 npm、package.json 与项目结构——本章两次「先照抄」的 package.json 是下一章的主角:npm install 那几秒钟里发生了什么、node_modules 里装的是什么、dependenciesdevDependencies 差在哪,以及一个像 Pi 那样的多 package 仓库是怎么组织的。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)