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());

要点讲解:

实战 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 字段:错误分支穷尽,编译器帮你检查

要点讲解:

实战 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] 服务器内部错误

要点讲解:

小结

三个项目分别对应 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 配置

返回 TypeScript 教程目录

✈️💬