1. 先从整体上理解:这套技能链到底想解决什么问题
1.1 mattpocock-skills 到底是什么
看到mattpocock-skills这个名字,很多人第一反应是:这是不是 Matt Pocock 本人写的一个开源项目?是不是又一个“照着抄一遍就能学会”的示例仓库?
其实可以把它理解成一套围绕 TypeScript 技能成长设计的练习型工程集合,收录的是从环境搭建、类型定义、接口联调,到把一个小需求完整落地的全过程。Matt Pocock 是 Total TypeScript 课程的作者,也是 TypeScript 社区里出镜率很高的讲师,他设计的技能练习思路通常都有一个共同点:不搞纯理论,用真实业务场景逼你把类型写对。
我在带新人时经常用这套思路,核心原因只有一个:很多人学 TypeScript 是“会语法但不会用”。单个的泛型、联合类型、类型收窄都看得懂,一旦面对真实需求就开始写any,最后把类型检查当成摆设。mattpocock-skills这类练习真正的价值,就是让你在“做完一个需求”的过程中重新理解:类型不是给编译器看的,是给你和下一个维护者看的。
所以这篇上手指南不只写安装配置,我会从环境准备一路讲到一个可运行的小型需求实现,把中间的取舍和坑都摊开说清楚。
1.2 为什么把“安装配置”看成一个严肃的技能点
很多初学者不愿意在环境上花时间,觉得“装个 Node.js 而已,有什么好学的”。但实际接需求时你会发现,环境配置往往决定了你一天的工作效率。装错 Node 大版本导致依赖安装失败、全局包权限冲突、终端里敲tsc提示不是内部或外部命令——这些我都见过,而且几乎每个新人都踩过其中的两三个。
把安装配置当成严肃技能点,是因为它背后还有一层逻辑:你要理解工具链是怎么串起来的。Node.js 负责运行时,npm 负责依赖管理,TypeScript 负责类型检查与编译,Git 负责版本控制,编辑器负责给你反馈。每个工具都有明确的边界和协作方式。看清楚这条链路,后面遇到“为什么我改了代码没生效”“为什么npm install报一堆错”这类问题时,你才有一个正确的排查方向。
1.3 从技能练习到真实需求的路径
这套上手指南会带你完成一个具体需求:一个带有任务增删改查能力的 API 服务。为什么选它?因为它覆盖了后端开发最常见的几个环节——数据建模、接口设计、参数校验、持久化、测试。规模不大,但“麻雀虽小,五脏俱全”,非常适合用来验证你前面学的工具链是否真的能串起来。
整个路径是这样的:先把 TypeScript 环境跑通,再写一份简单的需求规格说明,然后定义核心业务类型,接着用 Node.js 框架把接口实现出来,配好数据库持久化,最后补上测试。走完一遍之后,你会对“从需求到上线”这条链路有一个完整的体感,而不是只会写几个孤立的函数。
2. 新手必看:本地开发环境的安装与配置
2.1 Node.js、Git、编辑器的版本选择与安装
先说 Node.js。我建议装当前 LTS 版本,不要追最新大版本。比如写这篇文章时,Node 20 系和 22 系的 LTS 都是比较稳的选择。原因很简单:很多工具链、CI 镜像、云平台运行时都会优先兼容 LTS,你本地用最新版能跑,不代表部署环境也能跑。开发不是追新,而是求稳。
Git 的安装没什么悬念,Windows 上用 winget 装就行,macOS 上用 Homebrew 也行,装完以后记得配置用户名和邮箱:
git config --global user.name "your-name" git config --global user.email "your-email"编辑器我个人推荐 VS Code,不是因为它功能最多,而是它对 TypeScript 的支持几乎零成本,装上就有类型提示、跳转定义、重构这些能力。你不需要额外折腾。装完以后,再补两个插件:ESLint 和 Prettier,前者做静态检查,后者做格式化。
一个小提示:编辑器里的 TypeScript 版本可以由工作区决定。当你在项目里装了新的typescript包后,可以通过 VS Code 右下角或命令面板切到“Use Workspace Version”,确保你和同事用的是同一个 TypeScript 版本,避免“我本地没错、别人一拉代码就报错”的尴尬。
2.2 用 nvm 管理 Node 版本,避免权限地狱
我见过太多人卡在全局安装这一步:装个全局包要sudo,或者提示权限不足。这通常是因为直接用系统包管理器装了 Node.js,又试图往全局目录写入文件。解决方案很简单:不要直接装 Node,先装 nvm。
nvm(Node Version Manager)可以让你在用户目录下安装和管理多个 Node 版本,不需要管理员权限。安装方式:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash装完后重新打开终端,用命令查看可用版本并安装:
nvm ls-remote nvm install 20 nvm use 20 nvm alias default 20alias default这步很重要,它让新开的终端默认使用 Node 20,免得每次都要手动切。如果你手上同时维护多个项目,有的项目要求 Node 18,有的要求 Node 22,nvm 还能配合项目里的.nvmrc文件自动切换版本:
echo "20" > .nvmrc nvm use这是一次配置长期受益的习惯,强烈建议养成。
2.3 TypeScript 与 npm 项目的初始化
先建一个项目目录,然后初始化 npm:
mkdir mattpocock-skills-demo cd mattpocock-skills-demo npm init -y接着安装 TypeScript 和必要的运行时依赖。这里我推荐把 TypeScript 装在项目本地,而不是全局:
npm install --save-dev typescript tsx @types/nodetsx的作用是让你在开发阶段直接运行 TypeScript 文件,不用先tsc编译再node运行,这对开发体验的提升非常明显。
然后生成tsconfig.json:
npx tsc --init生成之后,我一般会改成下面这样:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "rootDir": "src", "outDir": "dist", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "declaration": false }, "include": ["src"], "exclude": ["node_modules", "dist"] }strict: true是必须开的,不能关。既然学了 TypeScript,就不要回到“任何类型都能互相赋值”的状态。strict模式会在你最痛的地方提醒你:函数参数少了、可能的null没处理、变量被推断成了undefined。这些提醒一开始觉得烦,习惯之后你会觉得这是最便宜的代码审查工具。
2.4 编辑器与 ESLint/Prettier 配置
接下来配置代码规范。先装依赖:
npm install --save-dev eslint @eslint/js typescript-eslint prettier eslint-config-prettierESLint 负责检查代码里的“坏味道”,比如未使用的变量、隐式类型转换、可疑的相等判断。Prettier 负责风格统一,比如单引号还是双引号、行宽多少、分号要不要。两者有重叠也有冲突,所以需要eslint-config-prettier把 ESLint 里和格式化相关的规则关掉,避免“格式化用 Prettier、检查用 ESLint”的混乱。
新建eslint.config.js:
import js from "@eslint/js"; import tseslint from "typescript-eslint"; import prettier from "eslint-config-prettier"; export default tseslint.config( js.configs.recommended, ...tseslint.configs.recommended, prettier );新建.prettierrc.json:
{ "semi": true, "singleQuote": true, "trailingComma": "all", "printWidth": 80 }到这里,你已经拥有了一个比较标准的 TypeScript 项目底座:版本可控、代码有检查、样式有规范。接下来可以正式进入需求阶段。
3. 从一句话需求到可落地的开发任务
3.1 需求规格书的关键要素
很多个人项目死在“没有需求”上,这里的“没有需求”不是说没想法,而是想法没有变成可执行的描述。你脑海里有个“做一个任务管理工具”的念头,可真要动手写代码时,你会发现根本不知道从哪儿开始:表结构是什么?接口有哪些?要不要登录?任务状态有几种?
所以我强烈建议,不管项目多小,先写一份极简需求规格说明。它可以不厚,但至少要包含五个要素:目标、用户、功能列表、数据要求、验收标准。
以我们要做的任务管理 API 为例,需求规格可以写成一页纸:
- 目标:提供一组 REST API,支持任务的创建、查询、更新和删除。
- 用户:前端开发人员(后续会接 APP 或 Web)。
- 功能列表:创建任务、查询任务列表、按 ID 查询任务详情、修改任务标题和状态、删除任务。
- 数据要求:任务包含唯一 ID、标题、状态、创建时间、更新时间。
- 验收标准:用 curl 或接口调试工具调用接口,能完成全流程;任务状态只能从给定枚举里选;过滤条件支持按状态筛选。
这份文档不要求用专业模板,重点是把自己从“产品经理的模糊描述”中拽出来,变成“开发人员能直接映射到接口和数据表”的清晰指令。
3.2 把需求拆成功能清单和数据模型
功能清单其实在上一步已经初步列出来了,现在要做得更细一点。每个功能要拆出输入、输出、异常情况。比如“创建任务”这个功能,输入是标题,可选的描述和截止日期;输出是创建后的完整任务对象;异常情况是标题为空字符串时,接口要返回 400,而不是默默地存一条空数据。
数据模型方面,我建议在动手写代码前先用 TypeScript 类型把模型定下来。这不只是“写代码前的仪式感”,而是因为数据模型就是前后端契约,类型定义就是最便宜的接口文档:
export type TaskStatus = "todo" | "done"; export interface Task { id: string; title: string; description?: string; status: TaskStatus; createdAt: Date; updatedAt: Date; }TaskStatus用了字面量联合类型,比用string好很多。好处在哪里?如果你在代码里写task.status = "finished",TypeScript 会立刻提示错误,而如果它是string,这种错误只有运行时才能发现。这种“把错误挡在编译前”的思路,就是类型系统最大的价值。
3.3 用类型先行的方法定义核心业务类型
类型先行(type-first)是一种开发方法:先定义类型,再写实现。这样做的好处是,你在写业务逻辑之前就想明白了数据的形状,而不是边写边改,最后类型和业务对不上。
举个例子,创建任务时,客户端传过来的数据里可能有多余字段,也可能缺少必填字段。我们需要区分“请求输入”和“持久化数据”两个类型:
export interface CreateTaskInput { title: string; description?: string; } export interface UpdateTaskInput { title?: string; status?: TaskStatus; description?: string; }CreateTaskInput不需要id和createdAt,因为这些是服务端生成的;UpdateTaskInput里所有字段都是可选的,因为更新操作允许只改标题、只改状态或只改描述。如果你把同一个类型同时用于输入和输出,往往会造成混乱:创建接口不知道怎么给新任务生成 ID,更新接口又要处理“客户端把状态字段一起传过来”的问题。
还有一层细节值得注意:CreateTaskInput的字段虽然类型上有title: string,但运行时你仍然需要校验它不能是空字符串。TypeScript 的类型检查和运行时数据校验是两码事,前者在编译期生效,后者在代码执行时生效。你无法用类型系统阻止一个恶意客户端往接口里传空字符串,所以后面要用运行时校验把这道门补上。
4. 核心功能实现:一个 TypeScript 任务管理 API
4.1 项目结构与路由设计
项目结构我习惯这样组织:
src/ types/ task.ts routes/ tasks.ts repositories/ taskRepository.ts index.tstypes放业务类型,routes放接口路由,repositories放数据访问层。这种分层不复杂,但边界清楚:路由层只关心 HTTP 请求和响应,仓储层只关心数据存取,类型定义是两者都依赖的契约。以后想换个数据库,只需要重写仓储层,路由和类型不用动。
路由设计上,我选择用 Fastify 而不是 Express。原因是 Fastify 的 TypeScript 支持更好,自带的请求校验机制也更完善,还内置了性能不错的 JSON 序列化。安装:
npm install fastify然后新建src/index.ts:
import Fastify from "fastify"; import { tasksRoutes } from "./routes/tasks.js"; const app = Fastify({ logger: true }); app.register(tasksRoutes, { prefix: "/tasks" }); const start = async () => { try { await app.listen({ port: 3000, host: "0.0.0.0" }); } catch (err) { app.log.error(err); process.exit(1); } }; start();注意这里使用了.js后缀引入模块。NodeNext 模式下,TypeScript 要求你在相对路径导入时带上扩展名,即使源文件是.ts,也要写.js。这是新手最容易疑惑的一点,我第一次遇到时也愣了一下,后来才明白这是为了符合 Node.js 原生 ES Module 的解析规则。
4.2 用 Zod 做运行时校验
前面说过,TypeScript 类型无法在运行时保护你的接口。要补上这道门,最吸引人的方案是 Zod。
Zod 是一个 schema 声明库,它兼具两个能力:运行时校验和类型推导。先用 Zod 定义校验规则,再通过z.infer推导出 TypeScript 类型。这样你只需要写一份 schema,类型和校验逻辑永远保持同步:
import { z } from "zod"; export const createTaskSchema = z.object({ title: z.string().min(1, "标题不能为空"), description: z.string().optional() }); export type CreateTaskInput = z.infer<typeof createTaskSchema>;在路由中使用这个 schema:
import { FastifyInstance } from "fastify"; import { createTaskSchema } from "../types/task.js"; import { createTask, listTasks, getTaskById, updateTask, deleteTask } from "../repositories/taskRepository.js"; export async function tasksRoutes(app: FastifyInstance) { app.post("/", async (request, reply) => { const parsed = createTaskSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ error: "参数校验失败", details: parsed.error.flatten() }); } const task = await createTask(parsed.data); return reply.status(201).send(task); }); app.get("/", async (request, reply) => { const query = request.query as { status?: string }; const tasks = await listTasks(query.status); return reply.send(tasks); }); }safeParse比parse更值得养成习惯。parse失败时直接抛异常,容易把错误变成 500;safeParse会把成功或失败包装在结果对象里,方便你自定义错误响应。对于面向用户的服务,返回清晰的 400 错误要比模棱两可的 500 好得多。
4.3 数据持久化的选择与实现
任务管理 API 的数据存储在早期阶段不需要上 PostgreSQL 或 MySQL,那样反而会把简单问题复杂化。先用 SQLite 把需求跑通,后续量上来了再切专业数据库也不迟。
Node.js 里操作 SQLite 比较轻量的方式是使用node:sqlite(Node 22 以后内置)或者better-sqlite3。我用better-sqlite3举例,因为它的 API 是同步的,代码写起来非常直觉,而且性能足够好:
npm install better-sqlite3 npm install --save-dev @types/better-sqlite3初始化数据库并建表:
import Database from "better-sqlite3"; const db = new Database("tasks.db"); db.exec(` CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT, status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'done')), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); `);建表语句里的CHECK约束是数据库层面的最后防线。即使你的应用层校验漏了,数据库也不会接受一个status为finished的任务。这种纵深防御的思路在后端开发里非常重要:不要把所有信任都放在一层。
接着实现仓储层:
import { randomUUID } from "node:crypto"; import type { Task, TaskStatus, CreateTaskInput, UpdateTaskInput } from "../types/task.js"; interface TaskRow { id: string; title: string; description: string | null; status: TaskStatus; created_at: string; updated_at: string; } function mapRowToTask(row: TaskRow): Task { return { id: row.id, title: row.title, description: row.description ?? undefined, status: row.status, createdAt: new Date(row.created_at), updatedAt: new Date(row.updated_at) }; } export function createTask(input: CreateTaskInput): Task { const now = new Date().toISOString(); const task: Task = { id: randomUUID(), title: input.title, description: input.description, status: "todo", createdAt: new Date(now), updatedAt: new Date(now) }; db.prepare( `INSERT INTO tasks (id, title, description, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)` ).run(task.id, task.title, task.description ?? null, task.status, now, now); return task; } export function listTasks(status?: string): Task[] { if (status) { const rows = db.prepare(`SELECT * FROM tasks WHERE status = ?`).all(status) as TaskRow[]; return rows.map(mapRowToTask); } const rows = db.prepare(`SELECT * FROM tasks ORDER BY created_at DESC`).all() as TaskRow[]; return rows.map(mapRowToTask); }用randomUUID()而不是自增 ID,是因为分布式场景下自增 ID 容易出问题,而且对外暴露自增 ID 还可能泄漏业务量。UUID 的代价是存储空间和索引效率略低,但在这个体量下完全可以忽略。
4.4 测试:vitest 与 supertest
好不容易把接口写出来了,不补个测试说不过去。我选 Vitest 搭配 Fastify 内置的app.inject方式做接口测试,不需要额外起服务,测试跑得更快也更稳定:
npm install --save-dev vitest然后在路由文件旁边新建tasks.test.ts:
import { describe, it, expect, beforeAll, afterAll } from "vitest"; import Fastify from "fastify"; import { tasksRoutes } from "./tasks.js"; const app = Fastify(); beforeAll(async () => { await app.register(tasksRoutes, { prefix: "/tasks" }); await app.ready(); }); afterAll(async () => { await app.close(); }); describe("tasks API", () => { it("创建任务成功", async () => { const res = await app.inject({ method: "POST", url: "/tasks", payload: { title: "学习 TypeScript" } }); expect(res.statusCode).toBe(201); const body = res.json(); expect(body.title).toBe("学习 TypeScript"); expect(body.status).toBe("todo"); }); it("标题为空时返回 400", async () => { const res = await app.inject({ method: "POST", url: "/tasks", payload: { title: "" } }); expect(res.statusCode).toBe(400); }); });测试是倒逼你写好代码的方式。当你发现一个接口很难测,通常说明它耦合了太多职责,比如路由里直接拼 SQL、 direkt读文件。把它拆成路由层和仓储层,测试就好写很多。测试不是写给老板看的,是写给下个星期的自己看的。
5. 常见问题与排查技巧实录
5.1 模块解析失败
症状:项目跑起来后,终端报错Cannot find module './routes/tasks.js'或ERR_MODULE_NOT_FOUND。
原因:你已经按照 NodeNext 规范在代码里写了.js后缀,但编译器报错说找不到文件。这通常是把rootDir或include配置错了,导致.ts源文件没有正确包含进来。也可能是因为你建了src目录,但tsconfig.json里的include还指向根目录。
排查方法:先看tsconfig.json里的include是否覆盖了你的文件路径,再确认moduleResolution是否为NodeNext。最稳妥的方法是我上面给的配置。此外,如果你是用 npm 而不是 pnpm,还要确保依赖确实装到了node_modules,而不是因为某些原因被隔离了。
还有一个小概率的问题:package.json里缺少"type": "module"或"type": "commonjs"声明。NodeNext 模式下,package.json的type字段会决定文件被当成 ES Module 还是 CommonJS 解析。加上"type": "module"能省掉很多导入导出不一致的烦恼。
5.2 类型报错:也许不是代码问题
最常见的一句报错是:Type 'string | undefined' is not assignable to type 'string'。看到它时先别慌,大多数情况不是代码逻辑错了,而是 TypeScript 在告诉你:这个值在运行时确实可能为undefined,你没有处理。
我见过一个真实案例:同事从数据库查询结果里取字段,死活编译不过,他尝试给字段加!断言语:
const title = row.title as string;这确实能编译通过,但隐患很大。如果数据库里的title因为某种历史原因真的是NULL,运行时就会出幺蛾子。更好的做法是显式处理:
if (row.title == null) { throw new Error("数据库数据异常:title 字段缺失"); }或者用mapRowToTask这类映射函数,把数据库行的可空字段转换成业务对象里的可选字段。相信我,花一分钟处理空值,比你三个月后在生产环境排查一个奇怪的空指针要划算得多。
5.3 端口占用与数据库连接失败
端口占用这个问题几乎人人都会遇到。你明明运行了npm run dev,终端却报EADDRINUSE或者提示端口 3000 已占用。解决思路不是换端口了事,而是找到占用进程:
Windows 下:
netstat -ano | findstr :3000 taskkill /PID 12345 /FmacOS/Linux 下:
lsof -i :3000 kill -9 12345数据库连接失败则是另一个常见麻烦。用 SQLite 时,最常见的问题是“数据库文件被占用”。这种文件型数据库在高并发下会遇到数据库锁,表现是报SQLITE_BUSY。解决方法是调整 busy timeout:
const db = new Database("tasks.db", { timeout: 5000 });timeout: 5000意思是如果数据库被其他进程锁住,最多等待 5 秒。这个配置能避免很多“莫名其妙”的失败。
5.4 环境变量配置
真实项目中,数据库路径、监听端口、日志级别这些都不应该硬编码在代码里,而应该放进环境变量。最轻量的读取方式是:
const port = Number(process.env.PORT ?? 3000);为了避免每次都要处理process.env的类型问题,可以用dotenv加载.env文件,再定义一个简单的配置模块:
npm install dotenvimport "dotenv/config"; interface AppConfig { port: number; databasePath: string; } export const config: AppConfig = { port: Number(process.env.PORT ?? 3000), databasePath: process.env.DATABASE_PATH ?? "tasks.db" };配置集中管理之后,换环境就只需要改.env,不需要动业务代码。这是我从多次“本地能跑、服务器上崩了”的经历中总结出来的经验。生产环境的数据库路径、监听地址和本地不一样是常态,配置外置是迟早要做的。
6. 最后再分享几句大实话
如果你完整走完了前面几步,你其实已经做了一件能力密度很高的事:从零开始配置环境,把需求拆成类型和接口,用运行时校验守住边界,把数据落到数据库,最后用测试锁定行为。这套流程我在不同项目里反复用,老实说,它比你看十遍教程有用得多。
我在实际带人的过程中发现,新手最容易高估“写业务代码”的难度,低估“环境配置和需求梳理”的复杂度。等你被 Node 版本、模块解析、端口冲突、数据库锁这些都折磨过一遍后再回头看,你会发现自己对工具链的理解完全不一样了。这就是这些坑存在的意义。
最后再分享一个小技巧:每次搭新项目时,不要每次都从零开始敲配置。你可以把这篇文章里的初始化步骤整理成自己的项目模板,甚至写成脚本一键初始化。我最初遇到 N 个项目都要重复配置一遍环境时,就把npm install、生成tsconfig.json、配置 ESLint 这些步骤固化成了模板脚本,实测非常节省时间。而且模板还能让你对环境配置的每个细节都了然于心,以后再遇到问题,排查起来比别人快得多。
这套技能链还可以继续往下延伸,比如接入 Redis 做缓存、用 Docker 做环境统一、写 CI 流水线自动跑测试。但那些都是后话,先把当前这个需求做透,你就已经完成了一次从“看教程”到“交付功能”的质变。祝你在下一次需求里少踩几个坑。