《TypeScript编程入门》2.3 第一个项目:从零到运行

本节把零散知识组装成一个能跑起来的完整项目:命令行温度换算器。从创建目录、初始化 npm、安装 typescript 与 @types/node,到编写 tsconfig.json、实现参数解析、编译运行、用 npm scripts 固化流程,再到接入监听模式形成闭环。过程中会解释为什么命令行工具需要 @types/node,以及 dist 该不该提交到 Git。读完你能独立从零搭出小型项目。

本节目标:把前两节的碎片知识拼成一个真实可用的项目。你会亲手创建目录结构、写配置、写源码、编译、运行、再把它固化成 npm scripts,最终得到一个「改代码 → 自动重编译 → 手动运行看结果」的完整闭环。这个项目小到只有几十行代码,但它用到的每个环节,在真实工程里一个都不会少。

2.3 第一个项目:从零到运行

看别人写的项目结构,和自己从空白目录搭出一个项目,是完全不同的两件事。前者只考验阅读能力,后者才会逼你面对一连串具体问题:package.json 里该写什么?tsconfig.json 该放哪一层?源码目录叫什么?产物目录要不要提交到 Git?

这一节我们用一个命令行温度换算器把这些决策全部走一遍。选它做第一个项目有几个好处:不需要浏览器、不需要框架、不需要数据库,一个终端就能验证结果;同时它又能自然地用上类型注解、函数签名、process.argv 参数解析这些后面会反复出现的技能。

2.3.1 先明确项目要做什么

需求很简单:在命令行输入一个温度值和单位,程序输出换算后的结果。

# 输入摄氏 25 度,输出对应华氏温度
npm run start -- 25 C
# 期望输出:25°C = 77°F
# 反过来也可以
npm run start -- 77 F
# 期望输出:77°F = 25°C

别小看这个需求,它已经包含了工程项目的完整骨架:入口文件、纯函数逻辑、输入校验、错误处理、构建流程、运行命令。我们按顺序搭。

2.3.2 创建目录并初始化

mkdir ts-temp-convert && cd ts-temp-convert
npm init -y
npm install --save-dev --save-exact typescript@5.9.3 @types/node@22.20.5

两条安装命令值得解释。

typescript 是编译器,上一节已经讲过。@types/node 则是Node.js 内置 API 的类型声明包。TypeScript 只认识 JavaScript 语言本身的语法,它并不知道 process、fs、path 这些 Node 专有的全局对象存在。没有这个包,你在源码里写 process.argv 会直接报错:

error TS2580: Cannot find name 'process'. Do you need to install type definitions for node?
Try `npm i --save-dev @types/node`.

TypeScript 的报错信息质量一向很高,它甚至直接告诉你该装什么包。关于 @types 的机制与「为无类型库写声明」的完整方法,第 12 章会专门展开;现在你只要建立一个直觉:凡是用了平台或第三方库的能力,就要有对应的类型声明,否则编译器不认识它们。

装完后 package.json 会变成这样:

{
  "name": "ts-temp-convert",
  "version": "1.0.0",
  "description": "命令行温度换算器",
  "main": "dist/index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "devDependencies": {
    "@types/node": "22.20.5",
    "typescript": "5.9.3"
  }
}

这里为复现固定了两个包的精确版本,示例运行环境使用 Node.js 22。一般项目也会使用 ^5.9.3 等版本范围;范围的含义,以及如何用提交的 package-lock.json 和 npm ci 还原依赖树,属于依赖管理的独立话题,延伸阅读见 semver 依赖解析 。

2.3.3 建立目录结构

真实项目的目录结构不应该等到代码写乱了再重构。我们一开始就分成两半:源码进 src,产物进 dist。

mkdir src
touch src/index.ts
touch .gitignore

此刻的结构:

ts-temp-convert/
├── src/
│   └── index.ts
├── .gitignore
├── package.json
└── node_modules/

.gitignore 的内容:

node_modules/
dist/
*.log

dist 要不要提交到 Git? 不要。dist 是编译产物,它的内容完全由 src 和配置决定,提交它等于把「同一份信息」存两遍。更糟的是,一旦有人手动改了 dist 里的文件而没改源码,下一次编译就会无声地覆盖掉他的修改。库作者发布 npm 包时才需要把产物一起打包,那是发布流程的事,不是版本控制的事。

node_modules 当然也不提交。 它体积巨大且可由 package.json + package-lock.json 完全还原,同事克隆仓库后执行 npm install 即可。

2.3.4 写第一份真正用起来的 tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "moduleResolution": "node",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noEmitOnError": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

和上一节相比,这里增加 noUncheckedIndexedAccess 检查数组越界,并开启 sourceMap。strict 本身不包含数组越界检查。它会让编译器为每个 .js 额外生成一份 .js.map 文件,把运行时的行号映射回 TypeScript 源码。没有它,报错时堆栈显示的是 dist/index.js:42,你得对着产物找半天;有了它,调试器能直接停在你写的 .ts 那一行。开发阶段建议开启,发布时可按需关闭。

另外解释一下 moduleResolution: "node":它决定编译器去哪里找 import 的目标——按 Node 的规则逐层向上查 node_modules。第 11 章会讲它与 module 的搭配矩阵,现在保持 node 即可。

2.3.5 编写源码

打开 src/index.ts,写下第一版实现:

const C_TO_F_FACTOR = 9 / 5;
const FREEZING_POINT_F = 32;
/** 摄氏转华氏 */
function celsiusToFahrenheit(celsius: number): number {
  return celsius * C_TO_F_FACTOR + FREEZING_POINT_F;
}
/** 华氏转摄氏 */
function fahrenheitToCelsius(fahrenheit: number): number {
  return (fahrenheit - FREEZING_POINT_F) / C_TO_F_FACTOR;
}
/** 去掉多余的小数尾巴:77 而不是 77.0 */
function format(value: number): string {
  return Number.isInteger(value) ? String(value) : value.toFixed(1);
}
function main(): void {
  const [rawValue, rawUnit] = process.argv.slice(2);
  if (rawValue === undefined || rawUnit === undefined) {
    console.error("用法: npm run start -- 25 C");
    process.exit(1);
  }
  const value = Number(rawValue);
  if (rawValue.trim() === "" || !Number.isFinite(value)) {
    console.error(`无法解析温度值: ${rawValue}`);
    process.exit(1);
  }

  const unit = rawUnit.toUpperCase();

  if (unit === "C") {
    const result = celsiusToFahrenheit(value);
    console.log(`${format(value)}°C = ${format(result)}°F`);
  } else if (unit === "F") {
    const result = fahrenheitToCelsius(value);
    console.log(`${format(value)}°F = ${format(result)}°C`);
  } else {
    console.error(`不支持的单位: ${rawUnit},请使用 C 或 F`);
    process.exit(1);
  }
}

main();

这段代码里藏着几个值得停下来看的细节。

细节一:rawValue 的类型是 string | undefined。

process.argv.slice(2) 的返回类型是 string[],本节额外启用 noUncheckedIndexedAccess 后,数组解构才会带上 undefined——因为编译器无法保证下标 0 处真有元素。Number(rawValue) 本身允许传入 undefined,不会因此报错;如果省略判空后直接写 rawUnit.toUpperCase(),则会看到:

error TS18048: 'rawUnit' is possibly 'undefined'.

这段判空不是为了「让编译器闭嘴」,它对应的是真实的运行时场景:用户什么都不输入直接敲回车。编译期的 undefined 检查和运行时的参数缺失是同一件事的两面,这是 TypeScript 最典型的收益形态。

细节二:process.exit(1) 之后的代码不会继续执行。

@types/node 把 process.exit 的返回类型标注为 never,意思是「这个函数永远不返回」。编译器据此推断出:走到这里的分支已经终结,后续代码的类型收窄是安全的。这也是为什么上面的 unit === "C" 分支里不需要再判空。

2.3.6 编译并运行

先编译:

npx tsc

没有任何输出就是成功了——TypeScript 编译器的哲学是「沉默即正确」。此时目录里出现:

dist/
├── index.js
└── index.js.map

看一下产物,验证上一节讲的类型擦除:

// dist/index.js(节选)
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const C_TO_F_FACTOR = 9 / 5;
const FREEZING_POINT_F = 32;
/** 摄氏转华氏 */
function celsiusToFahrenheit(celsius) {
    return celsius * C_TO_F_FACTOR + FREEZING_POINT_F;
}
// ...

celsius: number 变成了 celsius,: number 的返回类型也没了。注释默认被保留,这由 removeComments 控制,与 target: "ES2020" 无关。

现在运行:

node dist/index.js 25 C
# 25°C = 77°F

node dist/index.js 77 F
# 77°F = 25°C

node dist/index.js
# 用法: npm run start -- 25 C

node dist/index.js abc C
# 无法解析温度值: abc

node dist/index.js 25 K
# 不支持的单位: K,请使用 C 或 F

node dist/index.js Infinity C
# 无法解析温度值: Infinity

五种输入、五种输出,全部符合预期。到这里,你已经完成了「从零到运行」的全部核心步骤。

2.3.7 用 npm scripts 固化流程

每次都敲 node dist/index.js 太啰嗦,而且没有人会记得住。把它们写进 package.json:

{
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsc --watch",
    "clean": "rm -rf dist"
  }
}

对应的用法:

命令作用什么时候用
npm run build编译一次改完代码准备运行前
npm run start -- 25 C运行程序并传参验证结果
npm run dev监听源码变化自动重编译连续改代码时开着
npm run clean清空产物目录产物状态可疑时

这里有个容易踩的坑:给 npm script 传参必须加 -- 分隔符。

# 正确:-- 之后的参数会传给脚本
npm run start -- 25 C

# 错误:25 和 C 会被 npm 自己吞掉,程序收不到参数
npm run start 25 C

-- 的含义是「npm 自身的参数到此为止,后面的是给被调用命令的」。写脚本命令时用得上,比如 npm run build -- --watch 也能把 --watch 透传给 tsc。

顺带一提,clean 里的 rm -rf 是 Unix 命令,Windows 上会失败。跨平台项目通常用 rimraf 这类包替代。如果你想系统地了解命令行工具的开发方式,可以延伸阅读 TypeScript 命令行工具开发 。

2.3.8 形成开发闭环

真正的日常开发节奏是这样的:

# 终端 A:常驻,负责编译
npm run dev

# 终端 B:改完代码后运行
npm run start -- 25 C

终端 A 会在每次保存后自动重编译并打印:

[10:00:00 AM] Starting compilation in watch mode...
[10:00:03 AM] Found 0 errors. Watching for file changes.
[10:01:13 AM] Found 0 errors. Watching for file changes.

如果你觉得「两个终端」还是麻烦,可以用 tsx 直接把 TypeScript 当脚本运行,跳过手动编译这一步:

npm install --save-dev tsx
npx tsx src/index.ts 25 C
# 25°C = 77°F

tsx 内部用 esbuild 做即时转译,省掉了写盘再运行的等待。它的代价是不做完整的类型检查——它只负责跑,不负责查。所以推荐的分工是:日常快速试跑用 tsx,提交前用 npm run build 做一次完整类型检查。想深入了解这类转译工具的原理,可延伸阅读 esbuild 原理 。

2.3.9 三个新手必踩的坑

坑一:忘记重新编译。

现象:改了 src/index.ts,npm run start 输出的还是旧结果。原因:你在运行 dist/index.js,而它没有跟着更新——运行的是产物,不是源码。解决:开着 npm run dev,或养成「改完先 build」的习惯。这个坑的变体是误以为 TypeScript 会边跑边编译,其实编译和运行是两件独立的事。

坑二:dist 目录结构多了一层。

现象:产物变成了 dist/src/index.js,package.json 里的 main 路径失效。原因:rootDir 没配,或某个文件越过了 src 边界,导致编译器推断出的公共根目录变成了项目根。解决:显式写上 "rootDir": "./src",并确保 include 不匹配 src 之外的文件。

坑三:修改 tsconfig.json 后 tsc --watch 没反应。

现象:改了配置里的 outDir,产物还是输出到老位置。原因:监听模式对配置变更的响应在不同版本间不一致,有时需要重启。解决:Ctrl+C 停掉 npm run dev 再启动,这是最省事的排查手段。

2.3.10 项目结构回顾

ts-temp-convert/
├── src/
│   └── index.ts          # 唯一的源码文件
├── dist/                 # 编译产物,不提交 Git
│   ├── index.js
│   └── index.js.map
├── .gitignore
├── package.json          # 依赖与 npm scripts
├── package-lock.json     # 精确版本锁定,要提交
└── tsconfig.json         # 编译器配置

对比一下本节开头那个「看别人项目」的困境,现在这七个文件里每一个的职责你都能说清楚。这就是本章想要的效果:不是记住模板,而是理解每个位置为什么放这个文件。

小结

本节完成了一个完整项目的全生命周期:创建目录 → 初始化 npm → 安装 typescript 与 @types/node → 建立 src/dist 分离的目录结构 → 配置 tsconfig.json → 编写带输入校验的源码 → 编译运行 → 用 npm scripts 固化流程 → 接入监听模式形成闭环。过程中我们确认了三件事:平台 API 需要类型声明包才能被编译器认识;类型擦除让产物变得简洁,但也意味着运行时没有类型保护;dist 属于产物,不该进版本库。

到这里,第 2 章「开发环境与工具链」就结束了。你已经拥有可复现的环境、能读懂并编写 tsconfig.json、并且亲手跑通了一个项目。下一章 3.1 原始类型与类型注解 正式开始讲语言本身——从 string、number、boolean 这些最基础的标注写起。如果你在搭建过程中还有不清楚的步骤,可以回看 2.1 安装 Node.js、TS 与编辑器配置 与 2.2 tsc 与 tsconfig.json 初探 。

阅读导航:上一节:2.2 tsc 与 tsconfig.json 初探 · 下一节:3.1 原始类型与类型注解 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes