跳到主要内容
预计阅读 66 分钟

前后端接口对不上——用共享类型打通全栈应用

一个反复出现的线上 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 包只包含类型定义和纯函数,不依赖任何运行时框架,可以被 backendfrontend 同时引用。这种”契约优先”的架构方式有一个显著好处:当产品需求要求修改某个数据结构时,开发者必须先修改 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 默认的 RequestResponse 类型过于宽泛——请求体是 any,查询参数是 any,路由参数也是 any。这意味着你在路由处理函数中访问 req.body.anyField 时 TypeScript 不会有任何警告,即使这个字段根本不存在。通过泛型扩展 RequestResponse,可以在编译期就锁定每个路由的数据形状:

// 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
  • 关闭 sourceMapdeclaration 以减小产物体积
  • 环境变量类型化管理:
// 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.tsSeverity 的类型定义后,前端代码会在下一次 tsc --noEmit 时立刻报出所有不兼容的位置。一个可能导致几小时排查的线上事故,被缩短为编译器输出的几行红色提示——这就是 TypeScript 全栈类型安全的价值所在。

购买课程解锁全部内容

告别类型错误:12 章掌握 TypeScript 工程实战

¥29.90