TypeScript 实战项目
学了 15 篇基础,是时候把它们串起来做点真东西了。这一章给三个完整、可运行的项目——类型安全 Todo、泛型 API 客户端、事件系统,每个都给出完整代码、运行结果和要点讲解。三个项目分别覆盖 TypeScript 的核心能力:类型建模(interface + 泛型 + 工具类型)、泛型封装(fetch + 类型守卫)、高级类型(mapped type + keyof + 判别联合)。建议你把代码复制到本地亲手编译跑一遍,把编译错误也亲手触发一遍——感受类型系统的"防呆"威力。
实战 1:类型安全 Todo(interface + 泛型 + localStorage)
需求:做一个待办管理 store,类型严格到编译期就能拦下所有非法输入——错误的 priority、缺失的字段、对内部数据的修改都不放过。
涉及知识:interface、泛型类、字面量联合类型 Priority、Omit/Partial 工具类型、类型守卫 Array.isArray、localStorage。
// todo.ts — 类型安全的待办管理 + localStorage 持久化
// 编译:tsc todo.ts && node todo.js
// 知识点:interface、泛型、字面量类型、类型守卫、localStorage、JSON 序列化
// 1. 基础 interface:描述单条 Todo 的形状
interface Todo {
id: string;
title: string;
done: boolean;
priority: Priority; // 联合字面量类型,编译期保证只能取这三个值
createdAt: number;
}
type Priority = "low" | "medium" | "high";
// 2. 泛型存储工具:可复用任何类型,与具体业务解耦
class Storage<T> {
constructor(private key: string) {}
save(items: T[]): void {
localStorage.setItem(this.key, JSON.stringify(items));
}
load(): T[] {
const raw = localStorage.getItem(this.key);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
// 类型守卫:运行时校验解析结果确实是数组,避免脏数据污染
return Array.isArray(parsed) ? parsed as T[] : [];
} catch {
return [];
}
}
clear(): void {
localStorage.removeItem(this.key);
}
}
// 3. 业务 store:封装增删改查,内部用泛型 Storage
class TodoStore {
private storage = new Storage<Todo>("todos.json");
private items: Todo[] = this.storage.load();
// Omit<T, K> 工具类型:让调用方传"不含 id/createdAt"的部分,其余字段必填
add(input: Omit<Todo, "id" | "done" | "createdAt">): Todo {
const todo: Todo = {
id: crypto.randomUUID(),
done: false,
createdAt: Date.now(),
...input, // 展开 title + priority
};
this.items.push(todo);
this.storage.save(this.items);
return todo;
}
// Partial<T>:所有字段可选,适合"部分更新"
update(id: string, patch: Partial<Omit<Todo, "id" | "createdAt">>): boolean {
const idx = this.items.findIndex((t) => t.id === id);
if (idx < 0) return false;
this.items[idx] = { ...this.items[idx], ...patch };
this.storage.save(this.items);
return true;
}
remove(id: string): boolean {
const before = this.items.length;
this.items = this.items.filter((t) => t.id !== id);
const changed = this.items.length < before;
if (changed) this.storage.save(this.items);
return changed;
}
// readonly 返回:外部不能修改内部数据(返回类型用 readonly 修饰)
list(filter?: { done?: boolean; priority?: Priority }): readonly Todo[] {
return this.items.filter((t) =>
(!filter?.done || t.done === filter.done) &&
(!filter?.priority || t.priority === filter.priority)
);
}
}
// 4. 使用示例
const store = new TodoStore();
store.add({ title: "买牛奶", priority: "high" });
store.add({ title: "写季度报告", priority: "medium" });
store.add({ title: "回邮件", priority: "low" });
console.log("全部:", store.list());
// [{ id: "...", title: "买牛奶", done: false, priority: "high", createdAt: 1717... }, ...]
// 类型错误会被编译器拦下:
// store.add({ title: "x", priority: "紧急" });
// ^^^^^^^^^^
// Argument of type '"紧急"' is not assignable to parameter of type 'Priority'.
const high = store.list({ priority: "high" });
console.log("高优先级:", high);
const first = store.list()[0];
store.update(first.id, { done: true });
console.log("标记完成后:", store.list());要点讲解:
- 字面量联合类型:
"low" | "medium" | "high"把取值范围收窄到三个字符串,编译器拦下任何越界值——比 enum 更轻量。 - 泛型类
Storage<T>:一次实现,任意类型复用——这是 TS 招牌能力,把"工具类"和"业务类型"解耦。 Omit<T,K>:让调用者只关心"必填部分",id/createdAt/done由 store 内部生成——这就是 TS 工具类型的威力。Partial<T>:让"部分更新"成为可能,所有字段可选,适合 PATCH 语义。- 类型守卫
Array.isArray:从unknown收窄到T[],运行时校验 + 编译期类型双保险,防 localStorage 脏数据。 readonly返回:外部拿到 list 后不能直接改内部数组,封装边界由类型系统守护。
实战 2:API 客户端(fetch + generics + 类型守卫)
需求:写一个泛型 request<T> 函数,所有 API 端点共用。返回类型由调用方指定,失败时抛出带 code 的 ApiError。
涉及知识:fetch、async/await、泛型函数、unknown vs any、类型守卫函数、判别联合、extends Error。
// api.ts — 泛型 API 客户端 + 类型守卫
// 知识点:fetch、泛型函数、async/await、类型守卫、conditional types、错误处理
// 1. 后端响应形状:成功与失败两种,用判别联合(discriminated union)
type ApiResponse<T> =
| { ok: true; data: T }
| { ok: false; error: { code: number; message: string } };
// 2. 实体 interface
interface User { id: number; name: string; email: string }
interface Post { id: number; title: string; authorId: number; body: string }
class ApiError extends Error {
constructor(public code: number, message: string) {
super(message);
this.name = "ApiError";
}
}
// 3. 核心:泛型请求函数,T 是期望的返回数据类型
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const resp = await fetch(`/api${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
// 用 unknown 而非 any,强制后续做类型守卫才能用
const body: unknown = await resp.json().catch(() => null);
if (!resp.ok) {
// 类型守卫:运行时检查形状 + 编译期收窄类型
if (isErrorBody(body)) {
throw new ApiError(body.error.code, body.error.message);
}
throw new ApiError(resp.status, resp.statusText);
}
// 类型守卫:确认是成功响应,取出 data
if (isSuccessBody<T>(body)) return body.data;
throw new ApiError(0, "响应格式不正确");
}
// 类型守卫函数:返回类型谓词 "x is X",编译器据此收窄类型
function isErrorBody(x: unknown): x is { ok: false; error: { code: number; message: string } } {
return (
typeof x === "object" && x !== null &&
"ok" in x && (x as any).ok === false
);
}
function isSuccessBody<T>(x: unknown): x is { ok: true; data: T } {
return (
typeof x === "object" && x !== null &&
"ok" in x && (x as any).ok === true &&
"data" in x
);
}
// 4. 业务封装:每个端点一个函数,类型推断让调用处无痛
export const api = {
// request<User[]> 的 <User[]> 让 T = User[],返回 Promise<User[]>
getUsers: () => request<User[]>("/users"),
getUser: (id: number) => request<User>(`/users/${id}`),
createPost: (data: Omit<Post, "id">) =>
request<Post>("/posts", { method: "POST", body: JSON.stringify(data) }),
};
// 5. 使用示例
async function main() {
try {
const users = await api.getUsers(); // users: User[]
console.log(`共 ${users.length} 个用户`);
const user = await api.getUser(1); // user: User
console.log(`姓名:${user.name}`);
const post = await api.createPost({
title: "Hello TS",
authorId: 1,
body: "泛型 + 类型守卫让 API 调用又安全又省心",
});
console.log(`新文章 id:${post.id}`);
// 下面这行会在编译期报错:Post 没有 foo 字段
// console.log(post.foo);
} catch (e) {
if (e instanceof ApiError) {
console.error(`[${e.code}] ${e.message}`);
} else {
console.error("未知错误", e);
}
}
}
main();
// 关键收益:
// - 泛型 request<T>:一次写好,所有端点都类型安全
// - unknown + 类型守卫:强迫你"先校验再用",根除 any 漏检
// - 判别联合 + ok 字段:错误分支穷尽,编译器帮你检查要点讲解:
request<T>:核心泛型函数,api.getUsers()自动推断T = User[],调用方零负担。unknown优于any:从 JSON 解析出来的数据是unknown,必须类型守卫才能用——这是 TS 防漏检的关键。- 判别联合:
ApiResponse<T>用ok: true/false区分成败,编译器会强制你两种情况都处理。 - 类型守卫函数:
x is X返回类型谓词,编译器据此把unknown收窄到精确类型,实现"运行时校验 + 编译期类型"双保险。 instanceof错误分类:catch 后用e instanceof ApiError区分业务错误和未知错误,处理路径清晰。- 对比 JS 版本:同样的功能用 JS 写不会少多少代码,但任何字段改名、形状变化都不会被编译器指出——TS 的收益随项目规模放大。
实战 3:事件系统(union type + mapped type + emit)
需求:实现一个类型安全的事件总线,编译期就检查"事件名是否存在、payload 类型是否匹配"。
涉及知识:泛型约束 K extends keyof E、mapped type、keyof、索引签名、函数重载、条件类型 E[K] extends void ? [] : [payload]。
// events.ts — 类型安全的事件系统(emit/on)
// 知识点:union type、mapped type、keyof、索引签名、函数重载
//
// 目标:编译期就能检查"事件名对不对、回调参数类型对不对"
//
// const bus = new EventBus<EventMap>()
// bus.on("login", (u) => console.log(u.name)) // u: User ✓
// bus.on("logout", (n) => console.log(n)) // n: number ✓
// bus.emit("login", { id: 1, name: "Alice" }) // ✓
// bus.emit("login", 123) // ✗ 编译错误
// bus.emit("signup", ...) // ✗ 不存在该事件
// 1. 定义事件映射:每个事件名对应一个 payload 类型
interface EventMap {
login: { id: number; name: string };
logout: number; // payload 是数字(token)
error: { code: number; message: string };
// 字符串字面量类型也可以作为 payload,表示"无数据"
ready: void;
}
// 2. mapped type + keyof:把 EventMap 转成"事件名 -> 回调函数"映射
// EventMap 的每个 key K 对应 (payload: EventMap[K]) => void
type HandlerMap<E> = {
[K in keyof E]?: (payload: E[K]) => void;
};
// 3. 泛型约束:K extends keyof E 保证只能 emit/on 已声明的事件
class EventBus<E extends Record<string, any>> {
// 索引签名:任意 string -> 回调数组(运行时存的还是数组)
private handlers: { [K in keyof E]?: Array<(p: E[K]) => void> } = {};
// 函数重载签名:on 的两个分支(void 和有 payload)
on<K extends keyof E>(event: K, handler: (payload: E[K]) => void): () => void {
(this.handlers[event] ??= []).push(handler);
// 返回一个取消订阅的函数
return () => this.off(event, handler);
}
off<K extends keyof E>(event: K, handler: (payload: E[K]) => void): void {
const arr = this.handlers[event];
if (!arr) return;
this.handlers[event] = arr.filter((fn) => fn !== handler);
}
// void 在 E[K] 时,调用方不必传 payload
emit<K extends keyof E>(event: K, ...args: E[K] extends void ? [] : [payload: E[K]]): void {
const arr = this.handlers[event];
if (!arr) return;
arr.forEach((fn) => fn(args[0] as E[K]));
}
once<K extends keyof E>(event: K, handler: (payload: E[K]) => void): () => void {
const off = this.on(event, (p) => {
off();
handler(p);
});
return off;
}
}
// 4. 使用示例
const bus = new EventBus<EventMap>();
const offLogin = bus.on("login", (user) => {
// user 自动推断为 { id: number; name: string }
console.log(`[${user.id}] ${user.name} 已登录`);
});
bus.on("logout", (token) => {
// token 自动推断为 number
console.log(`token ${token} 已注销`);
});
bus.on("error", (e) => {
// e 自动推断为 { code: number; message: string }
console.error(`[${e.code}] ${e.message}`);
});
bus.on("ready", () => {
console.log("系统就绪");
});
// 触发
bus.emit("ready");
bus.emit("login", { id: 1, name: "Alice" });
bus.emit("logout", 987654);
bus.emit("error", { code: 500, message: "服务器内部错误" });
// 取消订阅
offLogin();
bus.emit("login", { id: 2, name: "Bob" }); // 不会有输出,因为已取消
// === 编译期错误示例 ===
// bus.emit("login", 123);
// ^^^ Argument of type 'number' is not assignable to parameter of type '{ id: number; name: string }'.
// bus.emit("signup", {});
// ^^^^^^^ Argument of type '"signup"' is not assignable to parameter of type 'keyof EventMap'.
// 输出:
// 系统就绪
// [1] Alice 已登录
// token 987654 已注销
// [500] 服务器内部错误要点讲解:
keyof E:取出 EventMap 所有 key 的联合类型,作为emit/on的事件名参数——错了就编译失败。K extends keyof E:泛型约束保证 K 必须是已声明的事件名,这就是"类型安全事件总线"的根基。- mapped type
[K in keyof E]?:把EventMap的"事件→payload"映射转成"事件→回调函数"映射,中间用E[K]引用 payload 类型。 - 条件类型
extends void ? [] : [payload]:让void事件(无 payload)emit时不必传参数,普通事件必传——编译期精准控制。 - 返回取消订阅函数:
on()返回() => void,调用方调用即可取消订阅——避免内存泄漏的标准模式。 - 对比 JS 版:JS 写事件总线很简单,但事件名拼错、payload 类型错都不会报错,直到运行时崩溃——TS 在编译期全拦下。
小结
三个项目分别对应 TypeScript 的三大核心场景:类型建模(interface + 工具类型)、泛型封装(fetch + 类型守卫)、高级类型(mapped type + keyof)。它们都用到了前面学的基础——interface、泛型、联合类型、工具类型——只是组合起来解决真实问题。建议你至少把其中一个改造成自己的版本(给 Todo 加优先级排序、给 API 客户端加缓存层、给事件系统加 wildcard 监听),做过一遍比读十遍更扎实。
恭喜!到这里 TypeScript 系列就真正完结了。接下来可以挑一个方向深入——前端框架(TS + React/Vue/Angular 全家桶)、Node.js 后端(NestJS / Fastify)、类型体操(type-challenges)、开源贡献(VS Code / Deno / TypeScript 编译器本身都是 TS 写的)——TS 的世界以"类型安全 + 工程化"为核心,边做边玩进步最快。
← 上一篇 TypeScript tsconfig 配置