你写了一个 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 立刻报错:缺少 completed 和 createdAt 字段。这不是运行时 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/1用startsWith("/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(数据契约) RESTful(URL 契约)
│ │
▼ ▼
数据长什么样 数据在哪、怎么操作
│ │
└───────────┬───────────────┘
▼
前后端用同一套语义沟通
缺少任何一个,沟通成本都会急剧上升:
| 有契约 | 没契约 | |
|---|---|---|
| 数据形状 | 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 数组里的数据,刷新页面——前后端即刻同步。这就是契约带来的确定性。