本节目标:从空目录开始,搭出一套「今天能跑、半年后同事也能跑」的 TypeScript 工程脚手架。读完后你会拥有一个由 pnpm 管理依赖、由 tsx 负责开发期运行、由 tsup 负责生产构建的最小项目,并且清楚每一个选择背后的取舍。
1.1 从零搭建(pnpm / tsx / tsup)
入门书介绍了 TypeScript 的语言基础:它是 JavaScript 的超集,靠类型系统在编译期拦截错误。但真实项目里,语言只是最里面的一层。同一份 .ts 文件,写的人需要它能被直接执行、需要它报错时能定位、需要它打包后能发布到 npm 或塞进容器——这些都不属于语言范畴,而属于工程脚手架。
这一节我们不谈类型语法,只做一件事:把地基铺好。铺地基的顺序是「运行时 → 包管理器 → 目录结构 → 开发运行器 → 构建器」,每一层都验证一次。
1.1.1 为什么不能只用 tsc
新手最常见的做法是:装好 typescript,然后每次改完代码手动跑一遍 npx tsc,再用 node dist/index.js 执行。这个流程在第一个小时是可行的,在第一个星期就会崩掉。
问题出在反馈速度。tsc 的定位是「把整棵项目编译一遍并做全量类型检查」,它不是为「改一行、立刻看到结果」设计的。项目一大,一次全量编译就要几秒到几十秒,而开发期的编辑动作是每秒都在发生的。
于是社区分化出三类工具,各管一段:
| 角色 | 工具 | 核心诉求 | 代表 |
|---|---|---|---|
| 开发期运行器 | tsx、ts-node | 改了立刻跑,尽量不做类型检查 | 本节选用 tsx |
| 生产构建器 | tsup、esbuild、rollup | 产物小、启动快、可发布 | 本节选用 tsup |
| 类型检查器 | tsc(--noEmit) | 只报错,不产出文件 | 始终是 tsc |
关键认知是:「运行」和「检查类型」是两件可以拆开的事。开发期由 tsx 负责把 TS 转成 JS 立刻执行,类型正确性交给编辑器与一条独立的 typecheck 脚本;生产构建时再由 tsup 产出产物,构建前跑一次 tsc --noEmit 兜底。这套分工是后面所有章节的基础。
1.1.2 固定 Node 版本与包管理器
第一件事不是装依赖,而是锁死环境。工程脚手架的头号敌人是「我这儿能跑」,而它的根因永远是版本漂移。
先确认 Node 版本,并把它写进 package.json:
{
"name": "ts-app",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22 <23"
},
"packageManager": "pnpm@9.12.0"
}
engines 声明运行时范围,是否强制取决于安装工具的设置。通过 Corepack 的 shim 执行 pnpm 时,packageManager 才会参与版本选择;绕过 shim 直接使用全局 pnpm 不保证一致。
# Node 22 通常附带 Corepack;若发行包没有它,先 npm install -g corepack
# Node 25 起不再随 Node 分发 Corepack
corepack enable
# 进入项目目录后,Corepack 会按 packageManager 字段准备对应版本的 pnpm
corepack prepare pnpm@9.12.0 --activate
# 验证
pnpm -v
# 期望输出:9.12.0
为什么选 pnpm 而不是 npm? 三个实际差别:
- 磁盘与安装速度。pnpm 用内容寻址的全局 store,同一版本的依赖全机器只存一份,多个项目通过硬链接共享。重复安装可以复用已有缓存,实际速度仍受网络与安装脚本影响。
- 严格的依赖隔离。npm 会把依赖提升到扁平的
node_modules,于是你能import到一个从未在package.json里声明的「幽灵依赖」。pnpm 默认不做这种提升,没声明就是拿不到——这在写库时是救命的。 - monorepo 原生支持。pnpm 的 workspace 不需要额外工具,第 2 章讲路径别名与 monorepo 时会直接受益。
如果你更熟悉 npm 或 yarn,本节所有命令都有等价写法,工程结构完全一致。想了解包管理器在依赖解析上的更多细节,可延伸阅读 semver 依赖解析 。
1.1.3 目录结构与初始化
约定一套目录,是为了让「新文件放哪儿」这个问题永远不需要讨论:
mkdir -p ts-app/src ts-app/scripts
cd ts-app
pnpm init
目标结构如下:
ts-app/
├── src/ # 全部源码,只有这里进类型检查
│ └── index.ts
├── scripts/ # 构建、发布等辅助脚本,也是 TS
├── dist/ # 构建产物,git 忽略
├── package.json
├── tsconfig.json # 编辑器与类型检查用(下一节详解)
└── .gitignore
.gitignore 至少包含三行:
node_modules/
dist/
*.tsbuildinfo
*.tsbuildinfo 是可重新生成的增量编译缓存,通常应忽略,避免缓存随源码变动进入提交。
1.1.4 安装开发依赖
一次性装齐本节需要的四个包:
pnpm add -D --save-exact typescript@5.9.3 tsx@4.20.5 tsup@8.5.0 @types/node@22.20.5
四个包的分工:
| 包 | 用途 | 是否进生产依赖 |
|---|---|---|
typescript | 提供 tsc,只做类型检查 | 否 |
tsx | 开发期直接运行 .ts | 否 |
tsup | 打包出可发布的 JS 产物 | 否 |
@types/node | Node 内置模块(fs、path)的类型 | 否 |
注意四个全是 -D(开发依赖)。原因是:它们都只在开发机与 CI 上工作,运行时不依赖它们。构建产物是纯 JS,部署时只需要 node dist/index.js,连 node_modules 都可以只装生产依赖。
这一点常被写错。如果把 tsup 放进 dependencies,你的 Docker 镜像里就会白白多出一个打包器,镜像体积涨几十兆,还可能带来供应链风险。
先保存最小 tsconfig.json,否则 typecheck 没有项目配置;下一节再拆分配置层级。本节使用 Node 22、TS 5.9.3,工具版本写入锁文件,跨版本升级单独验证。
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
1.1.5 写第一个源文件
// src/index.ts
import { fileURLToPath } from "node:url";
export interface GreetingOptions {
readonly name: string;
readonly punctuation?: string;
}
export function greet({ name, punctuation = "!" }: GreetingOptions): string {
return `Hello, ${name}${punctuation}`;
}
const currentFile = fileURLToPath(import.meta.url);
console.log(greet({ name: "TypeScript" }));
console.log(`running from: ${currentFile}`);
两处值得注意的细节。
第一,import ... from "node:url" 使用了 node: 前缀。在 ESM 下这是推荐写法,它明确告诉读者「这是 Node 内置模块」,也让打包器不会去 node_modules 里找一个同名包。
第二,import.meta.url 只有在 ESM 下才存在。因为我们在 package.json 里写了 "type": "module",所有 .ts/.js 都被当作 ESM 处理。如果漏了这一行,运行时会报:
SyntaxError: Cannot use 'import.meta' outside a module
这是新手最容易踩的坑之一。ESM 与 CommonJS 的互操作细节较多,想系统了解可以延伸阅读 TypeScript 模块解析:ESM 与 CJS 和 Node.js 模块系统与 ESM 。
1.1.6 tsx:开发期直接运行 TS
现在配置脚本。package.json 的 scripts 一节:
{
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"build": "pnpm typecheck && tsup",
"typecheck": "tsc --noEmit"
}
}
先跑开发模式:
pnpm dev
# Hello, TypeScript!
# running from: /Users/you/ts-app/src/index.ts
tsx watch 会监听文件变化并自动重启。改一下 greet 里的问候语,保存,终端里的输出立刻更新,全程无需等待类型检查。
tsx 为什么这么快? 它底层用 esbuild 做转译,而 esbuild 是 Go 写的、可以多核并行,转译阶段只做「剥掉类型」,不做类型验证。这正是我们要的分工——类型错误由编辑器即时提示、由 typecheck 脚本兜底。想深入了解 esbuild 的机制,可延伸阅读 esbuild 原理
。
这里必须强调一个纪律:tsx 能跑通,不代表类型是对的。下面的代码 tsx 会照常执行:
// 类型错误,但 tsx 不报错
const count: number = "42";
console.log(count + 1); // 输出 "421"
"421" 这个输出就是「跳过类型检查」的代价。所以第 4 章我们会把 typecheck 接进测试与 CI 门禁,让它不可能被绕过。
1.1.7 tsup:生产构建与产物形态
开发期用 tsx,发布时用 tsup。先建一个最简配置:
// tsup.config.ts
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
target: "node22",
outDir: "dist",
clean: true,
sourcemap: true,
dts: true,
splitting: false,
minify: false,
});
逐项解释:
| 选项 | 含义 | 为什么这样选 |
|---|---|---|
entry | 入口文件 | 有多个入口时写成数组或对象 |
format | 产物模块格式 | 服务端只出 esm;要发布给旧环境才加 cjs |
target | 语法降级目标 | 与 engines.node 保持一致 |
clean | 构建前清空 outDir | 避免旧文件残留造成的假象 |
sourcemap | 生成 source map | 生产报错栈能映射回 TS 源码,第 2.3 节详讲 |
dts | 生成 .d.ts | 发布给他人用时必需;纯应用可关掉省时间 |
minify | 压缩 | 服务端不开,压缩后的栈几乎不可读 |
执行构建:
pnpm build
dist/ 里会出现 index.js、index.js.map,如果开了 dts 还有 index.d.ts。此时 pnpm start 会用纯 Node 跑起来,全程不碰 TypeScript。
一个真实对比。 同一个入口,tsc 与 tsup 的差别:
| 维度 | tsc | tsup |
|---|---|---|
| 是否做类型检查 | 是 | JS 转译不检查;生成 dts 时可能检查,仍需独立 typecheck |
| 速度(中等项目) | 秒级到十几秒 | 百毫秒级 |
| 能否打包依赖 | 否,只逐文件转译 | 是,可 bundle |
| 能否输出多格式 | 否,一次一种 | 是,esm + cjs 同时 |
| 产物是否含类型声明 | 是 | 需显式 dts: true |
结论很清楚:类型检查交给 tsc,产物生成交给 tsup。两者不是替代关系。
如果你的项目要发布成 npm 包,还要处理 exports 字段、版本号与 files 白名单,这些在后续章节展开,可先延伸阅读 TypeScript SDK 包发布
。
1.1.8 常见坑与错误信息
坑一:ERR_MODULE_NOT_FOUND
现象:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/dist/utils'
imported from /app/dist/index.js
原因:ESM 下相对导入必须写完整扩展名。import { a } from "./utils" 在 CJS 下能工作,在 ESM 下不行。
// 错误
import { a } from "./utils";
// 正确
import { a } from "./utils.js";
注意即使源文件是 utils.ts,这里也要写 .js——因为运行时看到的是编译后的文件。这个规则初看反直觉,但它是 ESM 规范的一部分。
坑二:tsx 与 node 行为不一致
现象:pnpm dev 正常,pnpm start 报模块找不到。
原因:tsx 对扩展名做了宽容处理,Node 不做。解决方式是统一按 Node 的严格规则写导入,或者让 tsup 打包时把内部模块合并成一个文件(bundle 默认开启即可)。
坑三:依赖装成了 dependencies
现象:Docker 镜像比预期大很多,或 CI 里 pnpm install --prod 后构建失败。
原因:tsup、typescript 被误装进生产依赖,或反向地,运行时真正需要的包被装成了 -D。判断标准只有一条:这段代码在 dist/ 里会被执行吗?会就是生产依赖。
坑四:忘了 "type": "module"
现象:Cannot use import statement outside a module 或 import.meta 报错。
原因:没有声明模块类型时,Node 默认按 CJS 解析 .js。加上 "type": "module" 即可。
1.1.9 脚手架自检清单
进入下一节前,逐条确认:
| 检查项 | 命令 | 通过标准 |
|---|---|---|
| Node 版本 | node -v | v22.x(本节示例基线) |
| pnpm 版本被锁定 | pnpm -v | 与 packageManager 一致 |
| 依赖已安装 | pnpm ls -D | 含 typescript、tsx、tsup、@types/node |
| 开发模式可跑 | pnpm dev | 打印问候语并随改动自动重启 |
| 类型检查通过 | pnpm typecheck | 无输出即通过 |
| 构建可产出 | pnpm build | dist/index.js 存在 |
| 产物可执行 | pnpm start | 输出与 pnpm dev 一致 |
小结
本节从空目录搭出了一条完整的工具链:Corepack 与 packageManager 锁住 pnpm 版本,engines 声明 Node 要求,src 与 dist 分离源码与产物,tsx 负责开发期的快速反馈,tsup 负责生产构建,而类型正确性由独立的 tsc --noEmit 保证。核心原则只有一条:运行、构建、类型检查是三条独立的流水线,不要指望一个工具同时做好三件事。
脚手架搭好后,最先要面对的就是 tsconfig.json——它决定了哪些文件进检查、用哪套模块解析规则、严格程度开到多高。这个文件写错,后面每一个报错都会指向错误的方向。下一节 1.2 严格模式与 tsconfig 分层
就来拆开它。如果你对 TypeScript 在服务端的整体实践还想先有个印象,也可以延伸阅读 Node.js 中的 TypeScript 实践
。
阅读导航:下一节:1.2 严格模式与 tsconfig 分层 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。