interface + RESTful:用 Bun 搭建类型安全的 Todos API

12 阅读9分钟

你写了一个 API,前端同事问你:"返回的 todo 对象里,完成状态是叫 done 还是 completed?值是 true 还是 false?"——你翻了翻代码,连自己都有点含糊。

这不是命名习惯的问题。这是缺少契约的问题。

TypeScript 的 interface 给数据定契约,RESTful 规则给 URL 定契约。没有契约,前后端靠"口头约定"过日子;有了契约,编译器帮你检查,URL 自己会说话。

这篇文章用 Bun 从头搭一个前后端分离的 Todos API,把两个契约一次讲透。

一、interface:给数据上"合同"

第一层:interface 就是一份合同

一个 todo 对象应该包含什么?用大白话说:需要 id、标题、是否完成、创建时间。

interface 就是把这句话翻译成代码的"合同":

interface Todo {
  id: string;
  title: string;
  completed: boolean;
  createdAt: Date;
}

这段代码编译之后不会产生任何运行时的值——它纯粹是写给 TypeScript 编译器看的。它的全部作用就是一句承诺:凡是叫 Todo 的对象,必须恰好有这四个字段,缺一个、多一个、类型错一个,编译器直接报错。

你已经知道 TypeScript 是 JavaScript 的类型超集(已知),而 interface 就是这套类型系统在"对象形状"这个场景下的核心工具(新概念)——如果 type 别名是给任意类型起名字的万能标签,那 interface 就是专用于描述对象结构的精确模具。

第二层:不只四个字段那么简单

拿到 Todo 类型,立刻就能约束真实数据:

const todos: Todo[] = [
  { id: "1", title: "吃饭", completed: false, createdAt: new Date() },
  { id: "2", title: "睡觉", completed: false, createdAt: new Date() },
  { id: "3", title: "打豆豆", completed: false, createdAt: new Date() }
];

如果你只写 { id: "4", title: "跑步" } 就往数组里放,TypeScript 立刻报错:缺少 completedcreatedAt 字段。这不是运行时 undefined 的锅——在你保存文件的那一刻,VS Code 已经标红了。

约束写在前,后面所有代码就有了"锚":

function renderTodo(todo: Todo) {
  // IDE 自动提示 id, title, completed, createdAt
  // 输入 todo.complated → 直接标红,属性名拼错了
  return `${todo.title} ${todo.completed ? "✅" : "⬜"}`;
}

声明参数类型为 Todo 之后,函数体里访问 todo.xxx,IDE 只会提示真实存在的属性。打错字能被发现,字段名变更能在编译期定位到所有受影响的地方——这是"被动安全",你不需要刻意检查,工具替你盯着。

第三层:interface 在 OOP 中的位置

interface 不是孤立存在的语法糖,它扎根在面向对象编程(OOP)的体系中。

OOP 有三大支柱——封装、继承、多态。interface 主要干的是封装的活:把"这类对象应该有哪些属性和方法"封装成一个可引用的名字。有了这个名字,你写函数时只需要声明"我要一个 Todo",不需要关心这个 Todo 从数据库查的还是前端传的。

但 interface 只声明结构,不能写实现。如果既要声明契约、又要给部分默认逻辑,TypeScript 提供了抽象类(abstract class):

abstract class BaseTodo {
  abstract id: string;
  abstract title: string;
  completed: boolean = false;  // 给默认值——interface 做不了的事
  abstract getSummary(): string;
  toggle() {
    this.completed = !this.completed;  // 给默认方法——interface 做不了的事
  }
}

两者的分工:

interface抽象类
核心作用只声明结构声明结构 + 可选默认实现
多继承一个类可实现多个一个类只能继承一个
编译后完全消失保留为 JS 类
何时用纯契约,不关心实现多个类有相同逻辑需要共享
总结定义"能做什么"定义"是什么" + 默认"怎么做"

抽象类通过 implements 满足 interface 的约束——它是 interface 的"增强版",不是替代品。

到这里,核心关系已经清楚了:面向对象编程提供基础概念(封装/继承/多态),面向接口编程是更高层的设计原则——你依赖的是契约,不是具体实现。 interface 之所以被称为"设计模式的基础",是因为从策略模式到依赖注入,核心思想都是"针对接口编程,而不是针对实现编程"。

但数据的契约只解决了"后端内部怎么定义对象"。数据要通过 HTTP 才能到达前端——URL 也有自己的契约。

二、RESTful:URL 自己会说话

第一层:一切皆资源

假设你要设计获取所有待办事项的 API,URL 怎么写?

选项 A: GET /api/getAllTodos
选项 B: GET /api/todos/list
选项 C: GET /todos

三个都能跑,但哪个"一看就懂"?选项 C。这不是简洁与否的问题——选项 C 背后是一套 API 设计规则,叫做 RESTful

它的核心思想就一句:一切皆资源。URL 是资源的地址,不是动作的描述。

URL 就像文件路径。你要找"待办事项"这个资源,路径就是 /todos,而不是 /getTodoList——就像你不会把文件夹命名为 找照片,而是命名为 photos

第二层:URL 用名词,动作用 HTTP 方法

RESTful 的 URL 设计规则可以浓缩成:资源的名词 + 资源的操作(HTTP 动词)

同一个路径 /todos,HTTP 方法不同,含义就不同:

HTTP 方法URL含义
GET/todos获取全部待办
GET/todos/1获取 id=1 的待办
POST/todos新增一条待办
PUT/todos/1更新 id=1 的待办
DELETE/todos/1删除 id=1 的待办

URL 里只有名词(todos),动词全在 HTTP 方法里。这是 RESTful 和"能用就行"的分界线。

反过来看非 RESTful 的写法:

❌ GET  /getTodos
❌ POST /createTodo
❌ POST /deleteTodo?id=1

这些 URL 把动词写进了路径——路径描述的是"做什么动作",不是"访问什么资源"。接口少的时候没问题,接口一多,URL 列表就变成一堆凌乱的动词短语,没有统一规则。

第三层:路由——资源在代码里怎么分路

规则不难。难的是在代码里落地。我们用 Bun 服务器来演示。

先启动最简 HTTP 服务——5 行代码,确认服务器能跑:

const server = Bun.serve({
  port: 8080,
  async fetch(req) {
    return Response.json({ msg: "hello world" });
  }
});

运行后访问 http://localhost:8080,你会看到:

{ "msg": "hello world" }

这一步有两个关键概念。第一,port: 8080——端口号。一台服务器只有一个 IP 地址(就像一栋楼只有一个门牌号),但通过不同的端口可以同时提供多种服务:HTTP 服务用 8080,邮件服务用 25,就像一栋楼里有不同的房间号。第二,fetch 函数——HTTP 是基于请求-响应的协议:用户通过浏览器发送一个请求(req),服务器处理后返回一个响应(res)。Bun.serve 的 fetch 就是这个"接收请求 → 返回响应"的入口,所有请求都会先到达这里。

fetch 是所有 HTTP 请求的入口。不管用户访问什么路径,请求都先到这里。路由的工作就是在这里做判断:看了请求的路径和方法之后,把它分发到对应的处理逻辑——就像一个警察在路口根据目的地指挥车辆分流。

现在把 RESTful 规则落实到路由里:

const server = Bun.serve({
  port: 8080,
  async fetch(req) {
    const headers = { 'Access-Control-Allow-Origin': "*" };
    // 解析用户访问的 URL:https://baidu.com:port/pathname?a=1&n=2
    const url = new URL(req.url);

    // 路由 1: GET /todos —— 获取全部待办(精确匹配集合路径)
    if (req.method === 'GET' && url.pathname === "/todos") {
      return Response.json(todos, { headers });
    }

    // 路由 2: GET /todos/:id —— 获取单条待办(匹配 /todos/ 前缀,提取 id)
    if (req.method === 'GET' && url.pathname.startsWith("/todos/")) {
      const id = url.pathname.split("/")[2];  // 从路径中取出 id
      const todo = todos.find((t) => t.id === id);
      return Response.json(todo);
    }

    // 兜底:未匹配任何路由
    return Response.json({ msg: "hello world" });
  }
});

两个路由的匹配策略不同:

  • /todos=== 精确匹配——这是"集合资源",URL 就是路径本身
  • /todos/1startsWith("/todos/") + split("/")[2] 提取 id——这是"单个资源",URL 是路径 + 资源标识

这恰好对应 RESTful 中"集合资源"和"单个资源"的区分:/todos 是集合,/todos/1 是集合中的一个。URL 本身的层级结构就是资源关系的表达,不需要额外的查询参数来补充"我要哪一个"。

访问 http://localhost:8080/todos,返回三条待办数据;访问 http://localhost:8080/todos/1,只返回 id 为 "1" 的那条——URL 自己在说话。

三、前后端连起来

后端的两层契约(interface + RESTful)写好了。前端怎么消费?

async function main() {
  const res = await fetch("http://localhost:8080/todos");
  const data = await res.json();
  console.log(data);
  // 输出: [{ id: "1", title: "吃饭", ... }, { id: "2", ... }, { id: "3", ... }]
}
main();

await 让异步代码读起来像同步代码:先发请求,等结果回来,再处理数据。和 .then() 链式调用相比:

// 同样的功能,用 .then() 的写法
fetch("http://localhost:8080/todos")
  .then(res => res.json())
  .then(data => console.log(data));

await 的写法不需要嵌套回调,代码从上到下的顺序就是执行顺序。不是因为语法更高级,而是因为它不打断阅读顺序

完整的前端页面——请求 todos 并渲染到 DOM:

<body>
  <ul id="todos"></ul>
  <script>
    async function main() {
      const res = await fetch("http://localhost:8080/todos");
      const data = await res.json();
      todos.innerHTML = data.map(
        todo => `<li>${todo.title}</li>`
      ).join('');
    }
    main();
  </script>
</body>

打开这个 HTML,页面上显示:

• 吃饭
• 睡觉
• 打豆豆

interface Todo 定义数据形状,到 RESTful 路由暴露数据,再到前端 fetch 消费数据——整个链路的关键节点全部打通。

有一个细节值得留意:前端的 data 类型是 any,没有享受 interface 的类型检查。如果后端把 title 改成 name,前端不会在编译时报错,只会在运行时发现页面空白。在生产项目中,通常用 openapi-typescript 这类工具从后端接口自动生成前端类型——让类型安全跨过 HTTP 边界。这是可以深入的方向,这里先点到为止。

四、两个契约为什么是黄金搭档

回顾整个架构:

interface(数据契约)        RESTfulURL 契约)
     │                           │
     ▼                           ▼
  数据长什么样               数据在哪、怎么操作
     │                           │
     └───────────┬───────────────┘
                 ▼
          前后端用同一套语义沟通

缺少任何一个,沟通成本都会急剧上升:

有契约没契约
数据形状interface 约束,IDE 自动补全靠口头约定,翻代码/查文档
URL 语义/todos + GET 一看就懂/getAllTodos/fetchTodoList 各写各的
查错时机编译时报错运行时才发现字段不匹配
新人上手看 interface + 看 URL 列表即可需要老同事口述规则

interface 管"写代码时不出错",RESTful 管"设计 API 时不出错"。 一个是编译时约束,一个是设计时约束——覆盖的时间点不同,目标相同:把"靠记忆和默契"变成"靠规则和工具"。

如果只记住一件事

interface 是数据的合同,RESTful 是 URL 的合同。把两个合同写清楚,前后端之间就没有"我以为你知道"的灰色地带。

这篇文章实现了两个 GET 路由——获取列表和获取单条。完整的 CRUD 是自然的下一步:POST 新增、PUT 更新、DELETE 删除,同样的 RESTful 路径,用不同的 HTTP 方法表达操作——模式完全一样,加三个路由即可。

代码直接跑:

bun run server.ts

然后打开 index.html,你应该看到三条待办事项。改一下 todos 数组里的数据,刷新页面——前后端即刻同步。这就是契约带来的确定性。