第9章 9.1 TypeScript 与 Node.js:给 JavaScript 加上「类型保险」

上一章我们学会了用 clinic.js 和 v8-profiler 给 Node.js 应用做「体检」,找出性能瓶颈在哪里。但你有没有这种感觉:代码一长,变量传着传着就不知道是什么类型了,bug 藏在角落里,等你发现的时候已经在线上炸了。这一章我们要聊的,就是怎么让 JavaScript 拥有「类型系统」,在写代码的时候就提前发现低级错误,而不是等到用户报错才后悔。

🎯 开场:为什么 JavaScript 需要类型?

你有没有遇到过这种情况——

// 你的本意是这样
function add(a, b) {
return a + b;
}
add(1, "2"); // 结果是 "12",不是 3!

或者这样——

// 同事改了接口返回格式,你不知道
const user = getUser(); // 突然变成 null 了
console.log(user.name); // 炸了:Cannot read property 'name' of null

这种「写的时候没报错,跑起来才炸」的感觉,就像是不戴头盔骑摩托车——平时没事,出事就是大事。

TypeScript 就是 JavaScript 的「头盔」:它在运行之前就告诉你哪里可能出错。

说白了:TypeScript = JavaScript + 类型检查。它让你在写代码的时候就发现 bug,而不是等到用户打电话投诉。

什么是 TypeScript?

一句话:TypeScript 是 JavaScript 的超集,它给 JavaScript 加上了「类型声明」

类比一下:
- JavaScript = 裸奔跑步(快是快,但摔了就完蛋)
- TypeScript = 穿好装备再跑(稍微慢一点,但安全得多)

// JavaScript(自由但危险)
function greet(name) {
return "Hello, " + name.toUpperCase();
}
greet(123); // 运行时报错:name.toUpperCase is not a function

// TypeScript(提前告诉你)
function greet(name: string): string {
return "Hello, " + name.toUpperCase();
}
greet(123); // 编辑器直接标红:Argument of type 'number' is not assignable to parameter of type 'string'

看到了吗?TypeScript 在你写代码的瞬间就告诉你错了,不需要等到运行。

学完这章你能解决什么问题?

  • ✅ 写出「自解释」的代码,变量类型一目了然
  • ✅ 在 VS Code 里实时看到错误提示,不用等跑起来
  • ✅ 重构代码时不怕改坏,系统会告诉你哪里受影响
  • ✅ 读别人代码像读中文注释一样清楚

🧱 基础:TypeScript 核心概念

1. 类型声明:给变量穿上有标签的衣服

是什么:在变量后面加个 : 类型,告诉 TypeScript 这个变量应该是什么。

生活类比:就像超市货架上的标签——「饮料区」「零食区」「生鲜区」。你不会把洗发水放零食区,因为标签不允许。

怎么用

// 基本类型声明
let name: string = "小明";
let age: number = 25;
let isStudent: boolean = true;

// 数组类型
let scores: number[] = [98, 87, 92];
let names: Array<string> = ["小红", "小刚", "小丽"];

// 对象类型
let user: { name: string; age: number } = {
name: "小明",
age: 25
};

// 任意类型(尽量少用!)
let anything: any = "可以是任何东西";
anything = 123; // 不报错,因为是 any

解释:第三行 let isStudent: boolean = true;——boolean 就是「真假」类型,只有 truefalse 两个值。

配图1 - 配图1

2. 函数类型:函数的「使用说明书」

是什么:声明函数时写清楚「输入什么类型,输出什么类型」。

为什么要用:团队合作时,你不用看函数内部实现就知道怎么调用它。

怎么用

// 普通函数
function add(a: number, b: number): number {
return a + b;
}
console.log(add(1, 2)); // 3
console.log(add("1", "2")); // 错误:Argument of type 'string' is not assignable to parameter of type 'number'

// 可选参数(用 ? 表示)
function greet(name: string, greeting?: string): string {
if (greeting) {
return greeting + ", " + name;
}
return "Hello, " + name;
}
console.log(greet("小明")); // Hello, 小明
console.log(greet("小明", "早上好")); // 早上好, 小明

// 默认参数
function say(message: string = "默认值"): string {
return message;
}
console.log(say()); // 默认值

// 箭头函数
const multiply = (a: number, b: number): number => a * b;
console.log(multiply(3, 4)); // 12

解释:第 3 行的 : number返回值类型,告诉调用者这个函数会返回一个数字。

3. 接口(Interface):对象的「设计图纸」

是什么:定义一个对象的「形状」,包含哪些属性、什么类型。

生活类比:就像租房合同——规定好了「月租 5000」「押一付三」「禁止养宠物」。不符合合同要求的,房东有权拒绝。

怎么用

// 定义接口
interface User {
name: string;
age: number;
email?: string; // 可选属性(加 ?)
readonly id: number; // 只读属性(创建后不能改)
}

// 使用接口
function introduce(user: User): string {
return `我是${user.name},今年${user.age}岁`;
}

const xiaoming: User = {
name: "小明",
age: 25,
id: 1001 // 必须提供
};

console.log(introduce(xiaoming)); // 我是小明,今年25岁

// 错误示例:少了必填属性
// const wrong: User = { name: "小明" }; // 报错:Property 'age' is missing

解释:第 3 行的 email?: string 后面有个 ?,表示这个属性可有可无;第 4 行的 readonly id 表示这个属性创建后就不能改

4. 泛型(Generic):让代码像变形金刚一样灵活

是什么:写代码时不确定具体类型,但要求「前后一致」。

生活类比:就像快递盒子——不管里面装的是手机还是衣服,盒子本身的结构是一样的。泛型就是那个「盒子模板」,你可以塞任何东西进去,但盒子形状不变。

怎么用

// 不用泛型:写死类型,扩展性差
function firstElement(arr: number[]): number {
return arr[0];
}
function firstElementStr(arr: string[]): string {
return arr[0];
}

// 用泛型:一个函数搞定所有类型
function firstElement<T>(arr: T[]): T {
return arr[0];
}

const nums = [1, 2, 3];
const strs = ["a", "b", "c"];

console.log(firstElement(nums)); // 1(类型是 number)
console.log(firstElement(strs)); // a(类型是 string)

// 泛型接口
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}

const userResponse: ApiResponse<User> = {
data: xiaoming,
status: 200,
message: "成功"
};
console.log(userResponse.data.name); // 小明

解释:第 15 行的 <T> 是「类型参数」,调用 firstElement(nums) 时 TypeScript 自动推断 Tnumber,调用 firstElement(strs) 时自动推断 Tstring

配图2 - 配图2

5. 枚举(Enum):给常量起个好名字

是什么:给一组相关的常量定义语义化的名字。

为什么要用:比直接写数字或字符串更易读,而且改了枚举值,所有用到的地方自动更新。

怎么用

// 数字枚举
enum Status {
Pending,  // 0
Success,  // 1
Failed    // 2
}

console.log(Status.Pending); // 0
console.log(Status[0]); // Pending(反向映射)

// 字符串枚举(更安全,推荐)
enum OrderStatus {
Created = "CREATED",
Paid = "PAID",
Shipped = "SHIPPED",
Delivered = "DELIVERED"
}

function getStatusText(status: OrderStatus): string {
switch (status) {
case OrderStatus.Created:
  return "订单已创建";
case OrderStatus.Paid:
  return "已支付";
case OrderStatus.Shipped:
  return "已发货";
case OrderStatus.Delivered:
  return "已送达";
default:
  return "未知状态";
}
}

console.log(getStatusText(OrderStatus.Paid)); // 已支付

解释:第 3-6 行定义了一个枚举 Status,它的值默认是 0、1、2。调用 Status.Pending 就等于用 0,但比直接写 0 易读得多。

6. 联合类型与类型守卫:变量的「多重身份」

是什么:一个变量可以是多种类型之一。

生活类比:就像你的身份——在公司是「员工」,在家里是「家人」,在健身房是「会员」。你一个人有多个身份,TypeScript 里叫「联合类型」。

怎么用

// 联合类型:一个变量可能是 string 或 number
function printId(id: string | number): void {
console.log("ID:", id);
}
printId(123); // 可以
printId("ABC-001"); // 也可以

// 类型守卫:缩小变量的类型范围
function processValue(value: string | number): string {
if (typeof value === "string") {
// 在这里,TypeScript 知道 value 是 string
return value.toUpperCase();
} else {
// 在这里,TypeScript 知道 value 是 number
return value.toFixed(2);
}
}
console.log(processValue("hello")); // HELLO
console.log(processValue(3.14159)); // 3.14

// 自定义类型守卫
interface Cat {
meow(): void;
}
interface Dog {
bark(): void;
}

function isCat(animal: Cat | Dog): animal is Cat {
return (animal as Cat).meow !== undefined;
}

function makeSound(animal: Cat | Dog): void {
if (isCat(animal)) {
animal.meow();
} else {
animal.bark();
}
}

解释:第 21 行的 typeof value === "string" 叫「类型守卫」——它告诉 TypeScript 在这个分支里 valuestring 类型,可以安全调用 toUpperCase()


🔥 实战:3 个递进项目

项目 1(5 分钟):TypeScript 版「成绩统计器」

目标:用 TypeScript 写一个简单的成绩统计函数,体验类型检查的好处。

完整代码

// score-calculator.ts

interface Student {
name: string;
score: number;
}

// 计算平均分
function calculateAverage(students: Student[]): number {
const total = students.reduce((sum, student) => sum + student.score, 0);
return total / students.length;
}

// 找出最高分和最低分
function findExtremeScores(students: Student[]): { highest: Student; lowest: Student } {
const sorted = [...students].sort((a, b) => b.score - a.score);
return {
highest: sorted[0],
lowest: sorted[sorted.length - 1]
};
}

// 测试数据
const class3Students: Student[] = [
{ name: "小明", score: 92 },
{ name: "小红", score: 88 },
{ name: "小刚", score: 95 },
{ name: "小丽", score: 78 },
{ name: "小华", score: 85 }
];

// 执行统计
const average = calculateAverage(class3Students);
const extremes = findExtremeScores(class3Students);

console.log("=== 成绩统计 ===");
console.log(`平均分:${average.toFixed(1)}`);
console.log(`最高分:${extremes.highest.name} (${extremes.highest.score}分)`);
console.log(`最低分:${extremes.lowest.name} (${extremes.lowest.score}分)`);

预期输出

=== 成绩统计 ===
平均分:87.6
最高分:小刚 (95分)
最低分:小丽 (78分)

一句话解释:我们定义了 Student 接口作为「设计图纸」,TypeScript 会确保 students 数组里的每个对象都有 name(string)和 score(number)。


项目 2(15 分钟):Node.js 读取 CSV 文件并处理

目标:用 TypeScript 写一个脚本,从 CSV 文件读取销售数据,统计每个月的销售额。

前置准备:先创建一个 sales.csv 文件:

date,product,sales
2024-01-05,手机,5999
2024-01-15,耳机,299
2024-02-01,手机,5999
2024-02-20,充电器,99
2024-03-10,平板,3999
2024-03-25,手机,5999

完整代码

// sales-analyzer.ts
import * as fs from "fs";
import * as path from "path";

interface SaleRecord {
date: string;
product: string;
sales: number;
}

interface MonthlySales {
month: string;
total: number;
}

// 解析 CSV 文件
function parseCSV(filePath: string): SaleRecord[] {
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.trim().split("\n");
const records: SaleRecord[] = [];

// 跳过表头,从第二行开始
for (let i = 1; i < lines.length; i++) {
const [date, product, salesStr] = lines[i].split(",");
records.push({
  date: date.trim(),
  product: product.trim(),
  sales: parseInt(salesStr.trim(), 10)
});
}

return records;
}

// 按月统计销售额
function summarizeByMonth(records: SaleRecord[]): MonthlySales[] {
const monthlyMap = new Map<string, number>();

for (const record of records) {
const month = record.date.substring(0, 7); // 取 "YYYY-MM" 部分
const current = monthlyMap.get(month) || 0;
monthlyMap.set(month, current + record.sales);
}

// 转换为数组并排序
return Array.from(monthlyMap.entries())
.map(([month, total]) => ({ month, total }))
.sort((a, b) => a.month.localeCompare(b.month));
}

// 主程序
const csvPath = path.join(__dirname, "sales.csv");
const records = parseCSV(csvPath);
const monthlySales = summarizeByMonth(records);

console.log("=== 月度销售统计 ===");
for (const { month, total } of monthlySales) {
console.log(`${month}: ¥${total.toLocaleString()}`);
}

预期输出

=== 月度销售统计 ===
2024-01: ¥6,298
2024-02: ¥6,098
2024-03: ¥9,998

一句话解释parseCSV 函数返回 SaleRecord[] 数组,TypeScript 知道每条记录一定有 date(string)、product(string)、sales(number)三个字段。


项目 3(15 分钟):命令行待办清单工具

目标:综合运用 TypeScript 知识,写一个带持久化存储的命令行待办清单。

完整代码

// todo-cli.ts
import * as fs from "fs";
import * as path from "path";
import * as readline from "readline";

interface Todo {
id: number;
content: string;
completed: boolean;
createdAt: string;
}

enum Priority {
Low = "LOW",
Medium = "MEDIUM",
High = "HIGH"
}

interface TodoWithPriority extends Todo {
priority: Priority;
}

class TodoList {
private todos: TodoWithPriority[] = [];
private readonly filePath: string;

constructor(filePath: string) {
this.filePath = filePath;
this.load();
}

// 从文件加载
private load(): void {
if (fs.existsSync(this.filePath)) {
  const data = fs.readFileSync(this.filePath, "utf-8");
  this.todos = JSON.parse(data);
}
}

// 保存到文件
private save(): void {
fs.writeFileSync(this.filePath, JSON.stringify(this.todos, null, 2));
}

// 添加待办
add(content: string, priority: Priority = Priority.Medium): void {
const newTodo: TodoWithPriority = {
  id: Date.now(),
  content,
  completed: false,
  createdAt: new Date().toISOString(),
  priority
};
this.todos.push(newTodo);
this.save();
console.log(`✅ 已添加:[${priority}] ${content}`);
}

// 标记完成
complete(id: number): void {
const todo = this.todos.find(t => t.id === id);
if (!todo) {
  console.log(`❌ 未找到 ID 为 ${id} 的待办`);
  return;
}
todo.completed = true;
this.save();
console.log(`✅ 已完成:${todo.content}`);
}

// 列出所有
list(filter?: "all" | "active" | "completed"): void {
const filtered = this.todos.filter(t => {
  if (filter === "active") return !t.completed;
  if (filter === "completed") return t.completed;
  return true;
});

if (filtered.length === 0) {
  console.log("📋 没有待办事项");
  return;
}

console.log("\n📋 待办清单:");
for (const todo of filtered) {
  const status = todo.completed ? "✅" : "⬜";
  const priorityEmoji =
    todo.priority === Priority.High ? "🔴" :
    todo.priority === Priority.Medium ? "🟡" : "🟢";
  console.log(`${status} [${todo.id}] ${priorityEmoji} ${todo.content}`);
}
console.log();
}

// 删除
delete(id: number): void {
const index = this.todos.findIndex(t => t.id === id);
if (index === -1) {
  console.log(`❌ 未找到 ID 为 ${id} 的待办`);
  return;
}
const [removed] = this.todos.splice(index, 1);
this.save();
console.log(`🗑️ 已删除:${removed.content}`);
}
}

// 使用示例
const todoFile = path.join(__dirname, "todos.json");
const todoList = new TodoList(todoFile);

// 命令行测试
console.log("=== 待办清单工具 ===\n");

todoList.add("完成 TypeScript 作业", Priority.High);
todoList.add("阅读 Node.js 文档", Priority.Medium);
todoList.add("整理笔记", Priority.Low);

todoList.list();

todoList.complete(Number(process.argv[2] || "0"));
todoList.list("active");

预期输出

=== 待办清单工具 ===

✅ 已添加:[HIGH] 完成 TypeScript 作业
✅ 已添加:[MEDIUM] 阅读 Node.js 文档
✅ 已添加:[LOW] 整理笔记

📋 待办清单:
⬜ [1734567890001] 🔴 完成 TypeScript 作业
⬜ [1734567890002] 🟡 阅读 Node.js 文档
⬜ [1734567890003] 🟢 整理笔记

✅ 已完成:完成 TypeScript 作业
📋 待办清单:
⬜ [1734567890002] 🟡 阅读 Node.js 文档
⬜ [1734567890003] 🟢 整理笔记

一句话解释:我们用 TodoList 类封装了所有操作,用 save()load() 方法实现数据持久化,TypeScript 的类型系统确保了「每条待办一定有 id、content、completed 这些字段」。


💪 进阶:常见坑 + 调试技巧

坑 1:类型断言别滥用

// ❌ 错误:强行告诉 TypeScript "这是 string",但实际上是 number
const value: any = 123;
const str: string = value as string;
console.log(str.toUpperCase()); // 运行时才报错!

// ✅ 正确:先用类型守卫判断
const value2: string | number = 123;
if (typeof value2 === "string") {
console.log(value2.toUpperCase());
} else {
console.log(value2.toFixed(2));
}

坑 2:对象属性别漏掉

// ❌ 错误:接口定义的属性必须全部提供
interface Config {
host: string;
port: number;
}
const config: Config = { host: "localhost" }; // 报错:Property 'port' is missing

// ✅ 正确:要么补全,要么标记为可选
interface Config2 {
host: string;
port?: number; // 加 ? 表示可选
}
const config2: Config2 = { host: "localhost" }; // 现在 OK 了

坑 3:数组类型写反了

// ❌ 错误:写成 (string),意思是一个可以装任何东西的数组
const arr1: string = [1, 2, 3]; // 报错!

// ✅ 正确:number[] 表示「number 类型的数组」
const arr2: number[] = [1, 2, 3];

// 或者用泛型写法
const arr3: Array<number> = [1, 2, 3];

坑 4:null 和 undefined 是独立的类型

// ❌ 错误:以为 undefined 可以赋值给 string
let name: string = undefined; // 报错!

// ✅ 正确:需要明确声明
let name2: string | undefined = undefined; // 现在 OK 了

// 或者用非空断言(确定有值的时候)
let name3: string = "小明";
console.log(name3!.toUpperCase()); // ! 告诉 TypeScript 我确定不是 null/undefined

坑 5:箭头函数的返回值别忘了标

// ❌ 错误:忘记标注返回类型,TypeScript 推断为 void
const double = (n: number) => {
n * 2; // 少了 return!这只是个表达式,不会返回任何东西
};
console.log(double(5)); // undefined

// ✅ 正确:加上返回类型
const double2 = (n: number): number => {
return n * 2;
};
// 或者更简洁
const double3 = (n: number): number => n * 2;

调试技巧:用 console.log + 类型注解

// 遇到类型问题时,先加个 console.log 看看实际值
function processData(data: unknown) {
console.log("收到的数据:", data);
console.log("数据类型:", typeof data);

// 加上类型注解,帮助自己理清思路
if (typeof data === "object" && data !== null) {
console.log("是个对象");
}
}

processData({ name: "小明", age: 25 });
processData([1, 2, 3]);
processData("hello");

✏️ 练习题

练习 1(2 分钟):基础改写

  • 输入:把项目 1 的 class3Students 改成你自己的 3 个朋友的名字和成绩
  • 预期输出console.log 打印出正确的平均分和最高/最低分
  • 提示:直接改 students 数组里的值就行

练习 2(2 分钟):加个判断

  • 输入:在项目 1 的 calculateAverage 函数里加一个判断,如果数组为空就返回 0
  • 预期输出calculateAverage([]) 返回 0calculateAverage(class3Students) 返回 87.6
  • 提示:在函数开头加 if (students.length === 0) return 0;

练习 3(3 分钟):处理新数据

  • 输入:把项目 2 的 CSV 解析改成处理这个数据:
date,product,quantity
2024-01-05,可乐,10
2024-01-15,薯片,5
2024-02-01,可乐,8
  • 预期输出:输出每月销售总数(数量,不是金额)
  • 提示:把 sales 字段改成 quantity 就行

练习 4(3 分钟):串联两个项目

  • 输入:用项目 2 的 CSV 读取方式,读取一个包含 namescore 的 CSV,然后用项目 1 的统计函数处理
  • 预期输出:打印出平均分和最高/最低分
  • 提示:关键是让 CSV 的列名和 Student 接口的字段名对上

练习 5(5 分钟):分析报错

  • 输入:看下面这段代码,说出为什么报错,然后修复它
interface User {
name: string;
age: number;
}

const users: User[] = [
{ name: "小明", age: 25 },
{ name: "小红", age: "二十岁" }, // 这行报错
{ name: "小刚", age: 30 }
];
  • 预期输出:说出错误原因,并写出修复后的代码
  • 提示"二十岁" 是字符串,不是数字

作业:做一个「TypeScript 学生信息管理系统」

需求描述:用 TypeScript 写一个学生信息管理系统,可以从 JSON 文件读取学生数据,提供查询、添加、统计功能。

功能点
1. 从 students.json 文件读取学生列表
2. 支持按姓名查询学生信息
3. 统计全班平均年龄和男生/女生比例

加分项
1. 支持按年龄排序输出
2. 把结果保存到新文件

验收标准
- 能成功读取 students.json 并显示所有学生
- 输入姓名能正确返回学生信息(或「未找到」)
- 平均年龄和性别比例计算正确
- 代码有适当注释

students.json 示例

[
{ "name": "小明", "age": 15, "gender": "male", "score": 92 },
{ "name": "小红", "age": 14, "gender": "female", "score": 88 },
{ "name": "小刚", "age": 15, "gender": "male", "score": 95 }
]

📚 总结

本文学了 3 件事
- TypeScript 的「类型声明」让你在写代码时就发现 bug
- interface泛型 让代码像设计图纸一样清晰、可复用
- Node.js 配合 TypeScript 让你写出更健壮的服务器端代码

延伸资源
- TypeScript 官方文档(英文,但例子很清楚)
- 《TypeScript 入门教程》- 阮一峰(中文,适合零基础)
- TypeScript Playground(在线写 TS,无需安装)

互动钩子:你在写 JavaScript 的时候有没有遇到过「类型相关」的 bug?比如把数字和字符串混在一起导致计算出错?评论区聊聊,老粉优先回复!


📌 下章预告:写好了 TypeScript 代码,怎么确保它真的能跑?下一章我们来聊聊「测试」——用 Jest 和 Supertest 给你的代码装上「安全气囊」,每次修改都不怕改坏。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。