Learn
TypeScript/20-ecosystem-typing

生态中的类型实践

前面十九章讲的是「TypeScript 这门语言」。但你真正每天写的,是 Node 脚本、React 组件、HTTP 接口和配置文件。这一章把类型系统落到这些具体场景上,重点不是罗列 API,而是给出可复用的建模套路。

一条贯穿全章的原则:让非法状态无法表示(make illegal states unrepresentable)。类型不是为了让编译器高兴,而是为了让错误的代码写不出来。

1. Node.js 类型

1.1 @types/node 与内置模块

Node 本身是 JS 写的,类型来自 @types/node:

npm i -D @types/node

然后在 tsconfig.json 里通过 "types": ["node"] 控制加载范围。导入内置模块时一律加 node: 前缀,这样既能和 npm 包区分,也是 Node 官方推荐的写法:

import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";

1.2 process.env 的类型问题

process.env 的类型是 Record<string, string | undefined>。这意味着:

  • 每个变量都可能是 undefined;
  • 值永远是字符串,PORT 拿到的是 "3000" 而不是 3000。

很多项目用 declare global 给 ProcessEnv 加字段来「解决」这个问题:

declare global {
  namespace NodeJS {
    interface ProcessEnv {
      PORT: string;      // 声称一定存在
    }
  }
}
⚠️给 ProcessEnv 加声明是在撒谎

这种写法只是让类型检查闭嘴,运行时该是 undefined 还是 undefined。正确做法是在启动时集中解析并校验一次,之后全程使用解析产物,绝不再直接读 process.env。

1.3 类型安全的配置加载

下面这个小框架用泛型把「解析器」和「产物类型」绑定在一起:每个环境变量声明一次解析函数和默认值,配置对象的类型就自动推导出来了。

类型安全的环境变量解析
type EnvVar<T> = { parse: (raw: string) => T; fallback: T };
 
function envVar<T>(parse: (raw: string) => T, fallback: T): EnvVar<T> {
  return { parse, fallback };
}
 
const asString = (raw: string): string => raw;
const asNumber = (raw: string): number => {
  const n = Number(raw);
  if (Number.isNaN(n)) throw new Error("不是合法数字: " + raw);
  return n;
};
const asBool = (raw: string): boolean => raw === "1" || raw.toLowerCase() === "true";
const asEnum = <T extends string>(allowed: readonly T[]) => (raw: string): T => {
  const hit = allowed.find((a) => a === raw);
  if (hit === undefined) throw new Error("非法取值: " + raw);
  return hit;
};
 
// 从 schema 推导出配置对象的类型
type Config<S extends Record<string, EnvVar<unknown>>> = {
  [K in keyof S]: S[K] extends EnvVar<infer T> ? T : never;
};
 
function loadConfig<S extends Record<string, EnvVar<unknown>>>(
  schema: S,
  source: Record<string, string | undefined>,
): Config<S> {
  const table: Record<string, EnvVar<unknown>> = schema;
  const out: Record<string, unknown> = {};
  for (const key of Object.keys(table)) {
    const spec = table[key] as EnvVar<unknown>;
    const raw = source[key];
    out[key] = raw === undefined || raw === "" ? spec.fallback : spec.parse(raw);
  }
  return out as unknown as Config<S>;
}
 
const schema = {
  PORT: envVar(asNumber, 3000),
  HOST: envVar(asString, "127.0.0.1"),
  DEBUG: envVar(asBool, false),
  LEVEL: envVar(asEnum(["debug", "info", "warn"] as const), "info"),
};
 
process.env.PORT = "8080";
process.env.DEBUG = "true";
process.env.LEVEL = "debug";
 
const config = loadConfig(schema, process.env);
 
// 下面每个属性都有精确类型,不是 string
console.log("端口 + 1 =", config.PORT + 1);
console.log("主机大写:", config.HOST.toUpperCase());
console.log("模式:", config.DEBUG ? "调试" : "生产");
console.log("日志级别:", config.LEVEL);
 
try {
  loadConfig({ LEVEL: envVar(asEnum(["debug", "info"] as const), "info") }, { LEVEL: "trace" });
} catch (e) {
  console.log("校验拦截:", (e as Error).message);
}
💡现实项目里用 zod

上面的实现是为了展示原理。真实项目推荐用 zod 之类的 schema 库:z.object({ PORT: z.coerce.number().default(3000) }),一次声明同时得到运行时校验和 z.infer 出来的静态类型。核心思路完全一致——单一数据源,类型从运行时 schema 推导。

2. React 的类型

2.1 组件

不要再用 React.FC。它曾经隐式包含 children,且对泛型组件不友好。直接标注 props 参数即可:

type ButtonProps = {
  label: string;
  onClick: () => void;
  variant?: "primary" | "ghost";
  children?: React.ReactNode;
};
 
function Button({ label, onClick, variant = "primary", children }: ButtonProps) {
  return (
    <button className={variant} onClick={onClick}>
      {label}
      {children}
    </button>
  );
}

需要透传原生属性时,用 ComponentPropsWithoutRef:

type InputProps = React.ComponentPropsWithoutRef<"input"> & {
  error?: string;
};

2.2 Hooks

Hook关键点
useState初始值能推断就别写泛型;可能为 null 时写 useState<User | null>(null)
useRefDOM 引用用 useRef<HTMLDivElement>(null);可变值用 useRef<number>(0)
useReducerreducer 的 action 用可辨识联合,配 assertNever
useContext默认值给 null 并写一个抛错的 useXxx 包装,避免到处判空
useCallback泛型参数就是函数类型本身
type Action =
  | { type: "add"; text: string }
  | { type: "toggle"; id: number }
  | { type: "clear" };
 
type State = { items: { id: number; text: string; done: boolean }[] };
 
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "add":
      return { items: [...state.items, { id: Date.now(), text: action.text, done: false }] };
    case "toggle":
      return {
        items: state.items.map((i) => (i.id === action.id ? { ...i, done: !i.done } : i)),
      };
    case "clear":
      return { items: [] };
  }
}

这个 reducer 不写 default 也能通过检查——因为联合被穷尽了,函数所有路径都有返回值。这就是可辨识联合在 React 里最实用的地方。

2.3 Context 的空值处理

const ThemeContext = React.createContext<Theme | null>(null);
 
export function useTheme(): Theme {
  const ctx = React.useContext(ThemeContext);
  if (ctx === null) throw new Error("useTheme 必须在 ThemeProvider 内部使用");
  return ctx;
}

一次判空,全局受益——所有调用 useTheme() 的组件拿到的都是非空的 Theme。

3. HTTP 契约的类型化

前后端之间最容易出错的地方,是「接口返回什么」这件事只存在于文档里。解决思路是把路由表建模成一个类型,请求函数根据路由 key 推导出参数和返回值类型。

用类型描述 API 契约
type Routes = {
  "GET /users": {
    params: { page: number };
    result: { id: number; name: string }[];
  };
  "POST /users": {
    params: { name: string; email: string };
    result: { id: number; created: boolean };
  };
  "GET /health": {
    params: Record<string, never>;
    result: { status: string; uptime: number };
  };
};
 
type RouteKey = keyof Routes;
type Params<K extends RouteKey> = Routes[K]["params"];
type Result<K extends RouteKey> = Routes[K]["result"];
 
// 用一张本地表模拟服务端(真实项目里换成 fetch 即可)
const server: { [K in RouteKey]: (p: Params<K>) => Result<K> } = {
  "GET /users": (p) => [
    { id: p.page * 10 + 1, name: "Ada" },
    { id: p.page * 10 + 2, name: "Grace" },
  ],
  "POST /users": (p) => ({ id: p.name.length + p.email.length, created: true }),
  "GET /health": () => ({ status: "ok", uptime: 123 }),
};
 
async function request<K extends RouteKey>(key: K, params: Params<K>): Promise<Result<K>> {
  const handler = server[key] as unknown as (p: Params<K>) => Result<K>;
  await new Promise<void>((resolve) => {
    setTimeout(() => {
      resolve();
    }, 1);
  });
  return handler(params);
}
 
async function main(): Promise<void> {
  const users = await request("GET /users", { page: 2 });
  // users 的类型精确到 { id: number; name: string }[]
  console.log("用户列表:", users.map((u) => u.id + ":" + u.name).join(", "));
 
  const created = await request("POST /users", { name: "Linus", email: "l@k.org" });
  console.log("新建:", created.id, created.created);
 
  const health = await request("GET /health", {});
  console.log("健康:", health.status, health.uptime);
 
  // 写错 key、少传参数、访问不存在的字段,全都会在编译期报错
  console.log("契约生效,无需查文档");
}
 
void main();
ℹ️和真实生态的对应关系

这套「路由表即类型」的思路,正是 tRPC、openapi-typescript、GraphQL Codegen 在做的事情。区别只在于类型的来源:手写、从 OpenAPI 生成、还是从 schema 推导。理解了原理,用哪个库都不会迷失。

3.1 fetch 的返回值是 any 陷阱

const res = await fetch(url);
const data = await res.json();   // data 的类型是 any

res.json() 返回 Promise<any>,直接用会让类型安全从这里开始崩塌。至少要写成 unknown 再校验:

async function getJson<T>(url: string, guard: (v: unknown) => v is T): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error("HTTP " + res.status);
  const data: unknown = await res.json();
  if (!guard(data)) throw new Error("响应结构不符合预期");
  return data;
}

4. 品牌类型:让 ID 不会传错

userId 和 orderId 都是 string,编译器分不清。**品牌类型(Branded Types)**用一个不存在于运行时的幽灵字段区分它们:

品牌类型
declare const brand: unique symbol;
 
type Brand<T, B extends string> = T & { readonly [brand]: B };
 
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Cents = Brand<number, "Cents">;
 
// 唯一的构造入口,同时承担运行时校验
function toUserId(raw: string): UserId {
  if (!raw.startsWith("u-")) throw new Error("非法 UserId: " + raw);
  return raw as UserId;
}
function toOrderId(raw: string): OrderId {
  if (!raw.startsWith("o-")) throw new Error("非法 OrderId: " + raw);
  return raw as OrderId;
}
function toCents(yuan: number): Cents {
  return Math.round(yuan * 100) as Cents;
}
 
function loadUser(id: UserId): string {
  return "读取用户 " + id;
}
function formatMoney(c: Cents): string {
  return (c / 100).toFixed(2) + " 元";
}
 
const uid = toUserId("u-42");
const oid = toOrderId("o-7");
 
console.log(loadUser(uid));
// loadUser(oid);        // 编译错误:OrderId 不能当 UserId
// loadUser("u-42");     // 编译错误:裸字符串也不行
console.log("订单号:", oid);
 
console.log(formatMoney(toCents(19.99)));
 
// 品牌只存在于类型层,运行时就是普通值
console.log("运行时类型:", typeof uid, "| 值:", String(uid));
 
try {
  toUserId("x-1");
} catch (e) {
  console.log("构造校验:", (e as Error).message);
}
⚠️品牌类型不是银弹

品牌只在编译期存在,JSON.parse 出来的字符串照样能被断言成任何品牌。它的价值在于强制所有值都从校验过的构造函数进入系统。如果到处写 as UserId,这层保护就名存实亡了。

5. Express 与中间件

Express 的 Request 类型可以通过模块增强扩展(第 15 章讲过):

import type { Request, Response, NextFunction } from "express";
 
declare module "express-serve-static-core" {
  interface Request {
    user?: { id: string; role: "admin" | "user" };
  }
}
 
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
  if (req.user?.role !== "admin") {
    res.status(403).json({ error: "forbidden" });
    return;
  }
  next();
}

给路由处理器加上泛型参数,可以让 body 与 query 也有类型:

import type { RequestHandler } from "express";
 
type CreateUserBody = { name: string; email: string };
type CreateUserResp = { id: number };
 
const createUser: RequestHandler<Record<string, never>, CreateUserResp, CreateUserBody> = (req, res) => {
  res.json({ id: req.body.name.length });
};
🎯练习
  1. 给 loadConfig 加一个 required: true 选项:缺失时不使用默认值而是抛出「缺少必需环境变量」的错误,并让类型层区分「必填」与「有默认值」两种字段。
  2. 用品牌类型区分 Email 与普通 string,写出配套的类型守卫 isEmail,并让它同时服务于运行时校验和类型收窄。
  3. 把第 3 节的 Routes 扩展成支持路径参数(如 "GET /users/:id"),用模板字面量类型从 key 里提取出参数名。

小结

  • Node 内置模块用 node: 前缀导入;process.env 在启动时集中解析一次,之后只用解析产物
  • 配置用「schema 推导类型」的模式,运行时校验与静态类型出自同一份声明
  • React 里放弃 React.FC,直接标注 props;reducer action 用可辨识联合实现穷尽
  • 把 API 路由表建模成类型,请求函数按 key 推导参数与返回值——这是 tRPC 一类工具的核心思想
  • res.json() 是 any,必须先当作 unknown 再校验
  • 品牌类型让同为 string 的各种 ID 互不兼容,但必须封死构造入口
  • 下一章讲测试与类型测试 →