前后端接口对不上——用共享类型打通全栈应用
一个反复出现的线上 Bug
某 SaaS 团队的工单系统上线后频繁收到用户投诉:“编辑保存后数据丢失”。排查发现,后端将工单优先级字段从 "normal" | "urgent" | "critical" 改成了数字枚举 1 | 2 | 3,但前端仍然按字符串发送请求。由于后端接口没有做严格校验,入库时优先级字段被存为 null,导致后续查询和排序全部异常。
这类前后端数据契约不一致的问题,在没有类型共享机制的项目中几乎是必然会发生的。传统做法是靠接口文档或 Swagger 来对齐前后端,但文档是”被动同步”的——开发者改了代码之后可能忘记更新文档,文档和实际接口之间的差异往往要等到联调甚至上线后才被发现。
本章将动手构建一个完整的全栈应用,展示如何通过 TypeScript 的类型系统在编译期就捕获这类错误,让前后端共用同一份数据契约。当后端修改了字段类型,前端代码会在编译时立即报错——这种”主动同步”机制比任何文档都可靠。
架构设计:三包分治
采用 Monorepo 组织方式,将代码拆分为三个包:
incident-tracker/
package.json
tsconfig.base.json
packages/
contracts/ -- 共享类型与校验逻辑
src/
schema.ts
guards.ts
tsconfig.json
package.json
backend/ -- HTTP API 服务
src/
app.ts
handlers/
filters/
providers/
tsconfig.json
package.json
frontend/ -- Web 界面
src/
main.tsx
views/
composables/
transport/
tsconfig.json
package.json
核心原则:contracts 包只包含类型定义和纯函数,不依赖任何运行时框架,可以被 backend 和 frontend 同时引用。这种”契约优先”的架构方式有一个显著好处:当产品需求要求修改某个数据结构时,开发者必须先修改 contracts 包中的类型定义,然后 TypeScript 编译器会自动在所有引用处标出需要适配的位置。这就像修改建筑图纸后,所有施工现场都会收到变更通知——不会出现”后端改了前端不知道”的情况。
为什么是三包而不是两包?能不能前后端各自定义类型,然后手动保持同步?当然可以,但这恰恰就是我们开头描述的那种”靠人工纪律维护契约”的方式——它在项目初期看起来更简单,但随着接口数量增长和团队成员变动,人工同步的可靠性会急剧下降。独立的 contracts 包把”类型变更”变成了一个编译期可检测的事件,这比任何沟通流程都可靠。
初始化
mkdir incident-tracker && cd incident-tracker
npm init -y
// tsconfig.base.json
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
共享类型定义(packages/contracts)
核心实体与枚举
// packages/contracts/src/schema.ts
/** 工单严重程度 */
export type Severity = "low" | "medium" | "high" | "critical";
/** 工单生命周期状态 */
export type LifecycleStage = "open" | "investigating" | "resolved" | "closed";
/** 工单实体 */
export interface Incident {
uid: string;
headline: string;
detail: string;
severity: Severity;
stage: LifecycleStage;
reportedAt: string; // ISO 8601
lastModifiedAt: string;
responder?: string;
labels: string[];
}
/** 创建工单的请求体 */
export interface OpenIncidentPayload {
headline: string;
detail: string;
severity: Severity;
responder?: string;
labels?: string[];
}
/** 更新工单的请求体(全部字段可选) */
export type PatchIncidentPayload = Partial<
Pick<Incident, "headline" | "detail" | "severity" | "stage" | "responder" | "labels">
>;
/** 工单列表查询参数 */
export interface IncidentFilter {
stage?: LifecycleStage;
severity?: Severity;
keyword?: string;
offset?: number;
count?: number;
}
/** 通用 API 响应信封 */
export interface Envelope<T> {
ok: boolean;
result: T;
note?: string;
}
/** 分页数据结构 */
export interface PagedResult<T> {
records: T[];
totalCount: number;
currentOffset: number;
pageSize: number;
pageCount: number;
}
/** 错误响应 */
export interface FaultReport {
ok: false;
reason: string;
errorCode: string;
fieldErrors?: Record<string, string[]>;
}
校验工具函数
除了纯类型定义,contracts 包还适合存放与数据契约相关的校验函数。这些函数在前后端都可能用到:后端在接收请求时校验参数合法性,前端在提交表单前做预校验。把校验逻辑集中在 contracts 包中,确保前后端使用完全相同的校验规则。
// packages/contracts/src/guards.ts
import { OpenIncidentPayload, Severity, LifecycleStage } from "./schema";
const ALLOWED_SEVERITIES: Severity[] = ["low", "medium", "high", "critical"];
const ALLOWED_STAGES: LifecycleStage[] = ["open", "investigating", "resolved", "closed"];
export function isSeverity(val: string): val is Severity {
return ALLOWED_SEVERITIES.includes(val as Severity);
}
export function isLifecycleStage(val: string): val is LifecycleStage {
return ALLOWED_STAGES.includes(val as LifecycleStage);
}
export function isValidOpenPayload(input: unknown): input is OpenIncidentPayload {
if (typeof input !== "object" || input === null) return false;
const obj = input as Record<string, unknown>;
return (
typeof obj.headline === "string" &&
obj.headline.length > 0 &&
typeof obj.detail === "string" &&
typeof obj.severity === "string" &&
isSeverity(obj.severity)
);
}
后端:HTTP API 服务(packages/backend)
项目搭建
cd packages/backend
npm init -y
npm pkg set type=module # 启用 ESM,nanoid v4+ 仅支持 ESM
npm install express cors nanoid
npm install --save-dev typescript @types/express @types/cors tsx nodemon
// packages/backend/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"references": [{ "path": "../contracts" }]
}
精确约束 Express 的请求与响应类型
Express 默认的 Request 和 Response 类型过于宽泛——请求体是 any,查询参数是 any,路由参数也是 any。这意味着你在路由处理函数中访问 req.body.anyField 时 TypeScript 不会有任何警告,即使这个字段根本不存在。通过泛型扩展 Request 和 Response,可以在编译期就锁定每个路由的数据形状:
// packages/backend/src/typedefs.ts
import { Request, Response } from "express";
export interface StrictRequest<
RouteParams = Record<string, string>,
Body = unknown,
Query = Record<string, string | undefined>
> extends Request<RouteParams, any, Body, Query> {}
export interface StrictResponse<T = any> extends Response {
json: (payload: T) => this;
}
路由处理
// packages/backend/src/handlers/incidentHandlers.ts
import { Router } from "express";
import { nanoid } from "nanoid";
import {
Incident,
OpenIncidentPayload,
PatchIncidentPayload,
IncidentFilter,
Envelope,
PagedResult,
FaultReport,
} from "@incident-tracker/contracts";
import { StrictRequest, StrictResponse } from "../typedefs";
const router = Router();
// 内存存储(生产环境应替换为数据库)
const store = new Map<string, Incident>();
/** GET /api/incidents —— 查询工单列表 */
router.get(
"/",
(
req: StrictRequest<{}, unknown, IncidentFilter>,
res: StrictResponse<Envelope<PagedResult<Incident>>>
) => {
const { stage, severity, keyword, offset = 0, count = 20 } = req.query;
let pool = Array.from(store.values());
if (stage) pool = pool.filter((inc) => inc.stage === stage);
if (severity) pool = pool.filter((inc) => inc.severity === severity);
if (keyword) {
const kw = keyword.toLowerCase();
pool = pool.filter(
(inc) =>
inc.headline.toLowerCase().includes(kw) ||
inc.detail.toLowerCase().includes(kw)
);
}
const start = Number(offset);
const size = Number(count);
const totalCount = pool.length;
const records = pool.slice(start, start + size);
res.json({
ok: true,
result: {
records,
totalCount,
currentOffset: start,
pageSize: size,
pageCount: Math.ceil(totalCount / size),
},
});
}
);
/** POST /api/incidents —— 创建工单 */
router.post(
"/",
(
req: StrictRequest<{}, OpenIncidentPayload>,
res: StrictResponse<Envelope<Incident> | FaultReport>
) => {
const { headline, detail, severity, responder, labels } = req.body;
if (!headline || !detail || !severity) {
return res.status(400).json({
ok: false,
reason: "headline, detail, and severity are required",
errorCode: "MISSING_FIELDS",
});
}
const timestamp = new Date().toISOString();
const incident: Incident = {
uid: nanoid(12),
headline,
detail,
severity,
stage: "open",
reportedAt: timestamp,
lastModifiedAt: timestamp,
responder,
labels: labels ?? [],
};
store.set(incident.uid, incident);
res.status(201).json({
ok: true,
result: incident,
note: "Incident created",
});
}
);
/** GET /api/incidents/:uid —— 查询单个工单 */
router.get("/:uid", (
req: StrictRequest<{ uid: string }>,
res: StrictResponse<Envelope<Incident> | FaultReport>
) => {
const incident = store.get(req.params.uid);
if (!incident) {
return res.status(404).json({ ok: false, reason: "Incident not found", errorCode: "NOT_FOUND" });
}
res.json({ ok: true, result: incident });
});
/** PATCH /api/incidents/:uid —— 更新工单 */
router.patch("/:uid", (
req: StrictRequest<{ uid: string }, PatchIncidentPayload>,
res: StrictResponse<Envelope<Incident> | FaultReport>
) => {
const existing = store.get(req.params.uid);
if (!existing) {
return res.status(404).json({ ok: false, reason: "Incident not found", errorCode: "NOT_FOUND" });
}
const patched: Incident = { ...existing, ...req.body, lastModifiedAt: new Date().toISOString() };
store.set(patched.uid, patched);
res.json({ ok: true, result: patched });
});
/** DELETE /api/incidents/:uid —— 删除工单(模式同上,省略重复的 404 处理) */
router.delete("/:uid", (
req: StrictRequest<{ uid: string }>,
res: StrictResponse<Envelope<null> | FaultReport>
) => {
if (!store.has(req.params.uid)) {
return res.status(404).json({ ok: false, reason: "Incident not found", errorCode: "NOT_FOUND" });
}
store.delete(req.params.uid);
res.json({ ok: true, result: null, note: "Incident removed" });
});
export default router;
中间件类型化
// packages/backend/src/filters/faultCatcher.ts
import { ErrorRequestHandler } from "express";
import { FaultReport } from "@incident-tracker/contracts";
export class ServiceFault extends Error {
constructor(
public httpStatus: number,
public errorCode: string,
message: string
) {
super(message);
this.name = "ServiceFault";
}
}
export const faultCatcher: ErrorRequestHandler<any, FaultReport> = (
err, _req, res, _next
) => {
console.error("[Fault]", err);
if (err instanceof ServiceFault) {
return res.status(err.httpStatus).json({
ok: false,
reason: err.message,
errorCode: err.errorCode,
});
}
if (err instanceof SyntaxError && "body" in err) {
return res.status(400).json({
ok: false,
reason: "Malformed JSON in request body",
errorCode: "PARSE_FAILURE",
});
}
res.status(500).json({
ok: false,
reason: "Unexpected server error",
errorCode: "INTERNAL_FAULT",
});
};
// packages/backend/src/filters/accessLog.ts
import { RequestHandler } from "express";
export const accessLog: RequestHandler = (req, _res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
next();
};
应用入口
// packages/backend/src/app.ts
import express from "express";
import cors from "cors";
import incidentHandlers from "./handlers/incidentHandlers";
import { faultCatcher } from "./filters/faultCatcher";
import { accessLog } from "./filters/accessLog";
const server = express();
const PORT = process.env.PORT ?? 4000;
server.use(cors());
server.use(express.json());
server.use(accessLog);
server.use("/api/incidents", incidentHandlers);
server.get("/api/ping", (_req, res) => {
res.json({ alive: true, time: new Date().toISOString() });
});
server.use(faultCatcher);
server.listen(PORT, () => {
console.log(`Backend listening on http://localhost:${PORT}`);
});
前端:Web 界面(packages/frontend)
项目搭建
npm create vite@latest frontend -- --template react-ts
cd packages/frontend
npm install axios
HTTP 请求封装
前端与后端之间的通信层是类型安全链条中最薄弱的环节——HTTP 请求和响应本质上是无类型的字符串。将 HTTP 调用包装为类型安全的函数,就是在这个薄弱环节上加一层类型铠甲。通过泛型参数,每个 API 调用的请求体和响应体类型都在编译期被锁定:
// packages/frontend/src/transport/httpClient.ts
import axios, { AxiosInstance, AxiosResponse } from "axios";
import { Envelope } from "@incident-tracker/contracts";
const http: AxiosInstance = axios.create({
baseURL: "http://localhost:4000/api",
timeout: 8000,
headers: { "Content-Type": "application/json" },
});
export async function get<T, P extends Record<string, string | number | undefined> = Record<string, string | number | undefined>>(
path: string,
params?: P
): Promise<T> {
const resp: AxiosResponse<Envelope<T>> = await http.get(path, { params });
return resp.data.result;
}
export async function post<TBody, TResult>(
path: string,
body: TBody
): Promise<TResult> {
const resp: AxiosResponse<Envelope<TResult>> = await http.post(path, body);
return resp.data.result;
}
export async function patch<TBody, TResult>(
path: string,
body: TBody
): Promise<TResult> {
const resp: AxiosResponse<Envelope<TResult>> = await http.patch(path, body);
return resp.data.result;
}
export async function del(path: string): Promise<void> {
await http.delete(path);
}
// packages/frontend/src/transport/incidentService.ts
import {
Incident,
OpenIncidentPayload,
PatchIncidentPayload,
PagedResult,
IncidentFilter,
} from "@incident-tracker/contracts";
import { get, post, patch, del } from "./httpClient";
export function listIncidents(
filter?: IncidentFilter
): Promise<PagedResult<Incident>> {
return get<PagedResult<Incident>, IncidentFilter>("/incidents", filter);
}
export function getIncident(uid: string): Promise<Incident> {
return get<Incident>(`/incidents/${uid}`);
}
export function openIncident(data: OpenIncidentPayload): Promise<Incident> {
return post<OpenIncidentPayload, Incident>("/incidents", data);
}
export function patchIncident(
uid: string,
data: PatchIncidentPayload
): Promise<Incident> {
return patch<PatchIncidentPayload, Incident>(`/incidents/${uid}`, data);
}
export function removeIncident(uid: string): Promise<void> {
return del(`/incidents/${uid}`);
}
自定义 Hook
// packages/frontend/src/composables/useIncidentList.ts
import { useState, useEffect, useCallback } from "react";
import {
Incident,
IncidentFilter,
PagedResult,
} from "@incident-tracker/contracts";
import { listIncidents } from "../transport/incidentService";
interface IncidentListState {
incidents: Incident[];
totalCount: number;
currentOffset: number;
loading: boolean;
fault: string | null;
reload: () => void;
applyFilter: (f: IncidentFilter) => void;
}
export function useIncidentList(
initialFilter?: IncidentFilter
): IncidentListState {
const [incidents, setIncidents] = useState<Incident[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [currentOffset, setCurrentOffset] = useState(0);
const [loading, setLoading] = useState(false);
const [fault, setFault] = useState<string | null>(null);
const [filter, applyFilter] = useState<IncidentFilter>(initialFilter ?? {});
const load = useCallback(async () => {
setLoading(true);
setFault(null);
try {
const page: PagedResult<Incident> = await listIncidents(filter);
setIncidents(page.records);
setTotalCount(page.totalCount);
setCurrentOffset(page.currentOffset);
} catch (err) {
setFault(err instanceof Error ? err.message : "Failed to load incidents");
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
load();
}, [load]);
return {
incidents,
totalCount,
currentOffset,
loading,
fault,
reload: load,
applyFilter,
};
}
// packages/frontend/src/composables/useIncidentActions.ts
import { useState } from "react";
import {
Incident,
OpenIncidentPayload,
PatchIncidentPayload,
} from "@incident-tracker/contracts";
import { openIncident, patchIncident, removeIncident } from "../transport/incidentService";
interface IncidentActions {
busy: boolean;
fault: string | null;
open: (data: OpenIncidentPayload) => Promise<Incident | null>;
modify: (uid: string, data: PatchIncidentPayload) => Promise<Incident | null>;
remove: (uid: string) => Promise<boolean>;
}
export function useIncidentActions(): IncidentActions {
const [busy, setBusy] = useState(false);
const [fault, setFault] = useState<string | null>(null);
// 通用的异步操作包装:统一管理 loading 和错误状态
async function withGuard<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
setBusy(true);
setFault(null);
try {
return await fn();
} catch (err) {
setFault(err instanceof Error ? err.message : "Operation failed");
return fallback;
} finally {
setBusy(false);
}
}
const open = (data: OpenIncidentPayload) =>
withGuard(() => openIncident(data), null);
const modify = (uid: string, data: PatchIncidentPayload) =>
withGuard(() => patchIncident(uid, data), null);
const remove = (uid: string) =>
withGuard(async () => { await removeIncident(uid); return true; }, false);
return { busy, fault, open, modify, remove };
}
组件 Props 与事件类型
// packages/frontend/src/views/IncidentRow.tsx
import { FC, MouseEvent } from "react";
import { Incident, LifecycleStage, Severity } from "@incident-tracker/contracts";
interface IncidentRowProps {
incident: Incident;
onStageChange: (uid: string, newStage: LifecycleStage) => void;
onRemove: (uid: string) => void;
onInspect: (incident: Incident) => void;
}
const severityIndicator: Record<Severity, string> = {
low: "#4caf50",
medium: "#ff9800",
high: "#f44336",
critical: "#9c27b0",
};
const IncidentRow: FC<IncidentRowProps> = ({
incident,
onStageChange,
onRemove,
onInspect,
}) => {
const handleRemoveClick = (evt: MouseEvent<HTMLButtonElement>) => {
evt.stopPropagation();
if (window.confirm("Remove this incident?")) {
onRemove(incident.uid);
}
};
return (
<div className="incident-row" onClick={() => onInspect(incident)}>
<div className="row-header">
<h4>{incident.headline}</h4>
<span
className="severity-dot"
style={{ backgroundColor: severityIndicator[incident.severity] }}
>
{incident.severity}
</span>
</div>
<p>{incident.detail}</p>
<div className="row-controls">
<select
value={incident.stage}
onChange={(evt) =>
onStageChange(incident.uid, evt.target.value as LifecycleStage)
}
onClick={(evt) => evt.stopPropagation()}
>
<option value="open">Open</option>
<option value="investigating">Investigating</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
<button onClick={handleRemoveClick}>Remove</button>
</div>
</div>
);
};
export default IncidentRow;
表单组件
// packages/frontend/src/views/ReportForm.tsx
import { FC, FormEvent, useState, ChangeEvent } from "react";
import { OpenIncidentPayload, Severity } from "@incident-tracker/contracts";
interface ReportFormProps {
onReport: (data: OpenIncidentPayload) => Promise<void>;
submitting: boolean;
}
const ReportForm: FC<ReportFormProps> = ({ onReport, submitting }) => {
const [draft, setDraft] = useState<OpenIncidentPayload>({
headline: "",
detail: "",
severity: "medium",
labels: [],
});
const handleFieldChange = (
evt: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const { name, value } = evt.target;
setDraft((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (evt: FormEvent<HTMLFormElement>) => {
evt.preventDefault();
await onReport(draft);
setDraft({ headline: "", detail: "", severity: "medium", labels: [] });
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="headline">Headline</label>
<input
id="headline"
name="headline"
value={draft.headline}
onChange={handleFieldChange}
required
/>
</div>
<div>
<label htmlFor="detail">Detail</label>
<textarea
id="detail"
name="detail"
value={draft.detail}
onChange={handleFieldChange}
required
/>
</div>
<div>
<label htmlFor="severity">Severity</label>
<select
id="severity"
name="severity"
value={draft.severity}
onChange={handleFieldChange}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<button type="submit" disabled={submitting}>
{submitting ? "Submitting..." : "Report Incident"}
</button>
</form>
);
};
export default ReportForm;
主页面组装
// packages/frontend/src/main.tsx
import { FC } from "react";
import { LifecycleStage, OpenIncidentPayload } from "@incident-tracker/contracts";
import { useIncidentList } from "./composables/useIncidentList";
import { useIncidentActions } from "./composables/useIncidentActions";
import IncidentRow from "./views/IncidentRow";
import ReportForm from "./views/ReportForm";
const Dashboard: FC = () => {
const { incidents, loading, fault, reload } = useIncidentList();
const { busy, open, modify, remove } = useIncidentActions();
const handleReport = async (data: OpenIncidentPayload): Promise<void> => {
const created = await open(data);
if (created) reload();
};
const handleStageChange = async (
uid: string,
newStage: LifecycleStage
): Promise<void> => {
const updated = await modify(uid, { stage: newStage });
if (updated) reload();
};
const handleRemove = async (uid: string): Promise<void> => {
const ok = await remove(uid);
if (ok) reload();
};
if (fault) return <div className="fault-banner">Error: {fault}</div>;
return (
<div className="dashboard">
<h1>Incident Tracker</h1>
<ReportForm onReport={handleReport} submitting={busy} />
{loading ? (
<p>Loading...</p>
) : (
<div className="incident-list">
{incidents.map((inc) => (
<IncidentRow
key={inc.uid}
incident={inc}
onStageChange={handleStageChange}
onRemove={handleRemove}
onInspect={(item) => console.log("Inspect:", item)}
/>
))}
</div>
)}
</div>
);
};
export default Dashboard;
前后端类型同步的关键技术
类型守卫:运行时校验的最后防线
TypeScript 的类型系统是编译期特性——编译为 JavaScript 后,所有类型信息都被擦除。这意味着在运行时,你无法依赖类型系统来保证数据的正确性。API 返回的数据可能因为后端 Bug、网络中间件篡改、后端版本与前端不匹配等原因不符合预期。编译器只能保证”如果数据确实是这个类型,那么使用方式是安全的”,但它无法保证”数据确实是这个类型”。
因此,在前后端通信的边界处,必须补上运行时校验这一环。类型守卫是 TypeScript 提供的标准机制,它通过一个返回 boolean 的函数告诉编译器:“如果这个函数返回 true,那么参数就是指定的类型”:
import { Incident } from "@incident-tracker/contracts";
function isIncident(data: unknown): data is Incident {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
return (
typeof obj.uid === "string" &&
typeof obj.headline === "string" &&
typeof obj.severity === "string" &&
typeof obj.stage === "string"
);
}
async function safeGetIncident(uid: string): Promise<Incident> {
const raw = await get<unknown>(`/incidents/${uid}`);
if (!isIncident(raw)) {
throw new Error("Server returned malformed incident data");
}
return raw; // 类型已被收窄为 Incident
}
用 Zod 统一类型定义与运行时校验
手写类型守卫工作量大且容易遗漏。Zod 库可以在一处同时定义 schema 和 TypeScript 类型:
import { z } from "zod";
const IncidentSchema = z.object({
uid: z.string(),
headline: z.string().min(1),
detail: z.string(),
severity: z.enum(["low", "medium", "high", "critical"]),
stage: z.enum(["open", "investigating", "resolved", "closed"]),
reportedAt: z.string().datetime(),
lastModifiedAt: z.string().datetime(),
responder: z.string().optional(),
labels: z.array(z.string()),
});
// 从 schema 推导 TypeScript 类型
type Incident = z.infer<typeof IncidentSchema>;
// 运行时校验:不合法时抛出详细错误
function parseIncident(raw: unknown): Incident {
return IncidentSchema.parse(raw);
}
这样类型定义和校验逻辑只维护一份,避免两者脱节。在实际项目中,Zod 的使用非常广泛,它还支持从 schema 生成 JSON Schema(用于 API 文档)和 OpenAPI 定义,形成从类型定义到运行时校验再到接口文档的完整闭环。
另一个值得关注的库是 tRPC,它比共享类型更进一步——直接让前端通过类型安全的 RPC 调用后端函数,连 HTTP 路由定义都省了。但 tRPC 要求前后端使用同一个 TypeScript 项目(或 Monorepo),适用场景相对有限。对于前后端分属不同团队、不同仓库的大型项目,共享类型包仍然是最通用的方案。
部署要点
后端
{
"scripts": {
"build": "tsc",
"start": "node dist/app.js",
"dev": "nodemon --exec tsx src/app.ts"
}
}
生产环境注意事项:
- 用
tsc编译为 JavaScript 后再用node运行,不要在生产环境使用tsx - 关闭
sourceMap和declaration以减小产物体积 - 环境变量类型化管理:
// packages/backend/src/providers/settings.ts
interface ServerSettings {
port: number;
env: "development" | "production" | "staging";
allowedOrigin: string;
}
export function loadSettings(): ServerSettings {
return {
port: Number(process.env.PORT) || 4000,
env: (process.env.NODE_ENV as ServerSettings["env"]) || "development",
allowedOrigin: process.env.ALLOWED_ORIGIN || "http://localhost:5173",
};
}
前端
npm run build # Vite 输出到 dist/
确保 tsconfig.json 中设置 "noEmit": true,因为 Vite 使用 esbuild 进行编译,TypeScript 只负责类型检查。在 CI/CD 流水线中,建议在部署前同时运行前后端的 tsc --noEmit——这样可以确保任何类型不兼容的变更都会被拦截在部署之前。
容器化部署
FROM node:22-alpine AS compile
WORKDIR /workspace
COPY package*.json ./
COPY packages/contracts ./packages/contracts
COPY packages/backend ./packages/backend
RUN npm install
RUN npm run build --workspace=packages/contracts
RUN npm run build --workspace=packages/backend
FROM node:22-alpine AS serve
WORKDIR /workspace
COPY --from=compile /workspace/packages/backend/dist ./dist
COPY --from=compile /workspace/node_modules ./node_modules
EXPOSE 4000
CMD ["node", "dist/app.js"]
多阶段构建将编译环境和运行环境分离,最终镜像不包含 TypeScript 源码和开发依赖。这种方式有两个好处:一是减小镜像体积(生产镜像可能只有编译镜像的十分之一),二是减小攻击面(TypeScript 源码不会出现在生产环境中)。对于前端部分,通常将 Vite 构建的静态文件交给 Nginx 或 CDN 托管,不需要 Node.js 运行时。
本章回顾
本章从”前后端接口字段对不上”这一高频线上事故出发,完整构建了一个类型安全的全栈应用:
- 共享类型包(contracts):实体类型、请求/响应类型、校验函数集中定义,前后端共同引用
- 后端类型化:通过泛型扩展 Express 的
Request/Response,精确约束每个路由的输入输出 - 前端类型化:泛型 HTTP 封装、类型安全的 Hook、精确的组件 Props 和事件类型
- 运行时校验:类型守卫和 Zod 在 API 边界做最后一道防线
- 部署实践:生产环境用编译后的 JavaScript 运行,多阶段 Docker 构建分离编译与运行
核心理念:类型不仅是开发工具,更是前后端之间的契约。当这份契约以代码而非文档的形式存在时,违反契约的行为会在编译期被捕获,而不是等到用户投诉才被发现。
回到本章开头的那个 Bug——工单优先级字段从字符串改成了数字。如果使用了共享类型包,后端工程师修改 contracts/schema.ts 中 Severity 的类型定义后,前端代码会在下一次 tsc --noEmit 时立刻报出所有不兼容的位置。一个可能导致几小时排查的线上事故,被缩短为编译器输出的几行红色提示——这就是 TypeScript 全栈类型安全的价值所在。
购买课程解锁全部内容
告别类型错误:12 章掌握 TypeScript 工程实战
¥29.90