Developer Tools

TypeScript 最佳实践:编写更安全、更易维护的代码

掌握最关键的 TypeScript 模式——严格模式、工具类型、泛型、判别联合类型,以及如何避免常见的类型系统漏洞。

8分钟阅读

代码编辑器在显示器上展示 TypeScript 代码

TypeScript 在大多数专业 JavaScript 项目中已从可选变为必备。但"技术上算是 TypeScript"和"真正能捕获 Bug 的 TypeScript"之间存在天壤之别。这种差距归结为几个关键模式,正是它们解锁了类型系统的真正威力。

始终启用严格模式

TypeScript 配置中单项收益最高的改动:

// tsconfig.json
{
  "compilerOptions": {
    "strict": true
  }
}

strict 启用了一系列检查:

  • strictNullChecksnullundefined 不可赋值给其他类型
  • noImplicitAny — 变量不能隐式拥有 any 类型
  • strictFunctionTypes — 更严格的函数参数检查
  • strictPropertyInitialization — 类属性必须完成初始化

没有 strictNullChecks,TypeScript 会漏掉最常见的一类运行时错误。从第一天起就开启它——事后在大型代码库中补开会非常痛苦。

避免 any——改用 unknown

any 是类型系统的逃生出口,它会屏蔽所有错误,让 TypeScript 失去意义:

// ❌ any 会禁用所有类型检查
function processData(data: any) {
  data.nonExistentMethod(); // 没有报错!运行时崩溃。
}

// ✅ unknown 强制你在使用前先收窄类型
function processData(data: unknown) {
  if (typeof data === "string") {
    console.log(data.toUpperCase()); // 安全——已收窄为 string
  }
  if (data instanceof Error) {
    console.log(data.message); // 安全——已收窄为 Error
  }
}

unknownany 的类型安全版本。它强制你在使用之前先检查值的类型。对于 API 响应、JSON 解析以及任何来自外部来源的数据,都应使用它。

用判别联合类型实现穷举处理

TypeScript 最强大的模式之一:

type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: User[] };
type ErrorState   = { status: "error"; message: string };

type ApiState = LoadingState | SuccessState | ErrorState;

function render(state: ApiState) {
  switch (state.status) {
    case "loading":
      return <Spinner />;
    case "success":
      return <UserList users={state.data} />; // 此处 state.data 可用
    case "error":
      return <ErrorMessage msg={state.message} />; // 此处 state.message 可用
    default:
      // 如果新增状态但未处理,TypeScript 会在此处报错
      const _exhaustive: never = state;
      throw new Error(`Unhandled state: ${_exhaustive}`);
  }
}

末尾的 never 赋值是穷举性检查——如果你向 ApiState 新增了一个变体但没有在 switch 中处理它,TypeScript 会给出编译时错误。这是整个语言中最有价值的安全保障之一。

工具类型

TypeScript 内置了多种类型转换工具:

interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
  createdAt: Date;
}

// 将所有属性变为可选
type PartialUser = Partial<User>;

// 将所有属性变为必填
type RequiredUser = Required<User>;

// 将所有属性变为只读
type ReadonlyUser = Readonly<User>;

// 挑选特定属性
type UserPreview = Pick<User, "id" | "name">;

// 排除特定属性
type UserWithoutDates = Omit<User, "createdAt">;

// 从对象的值创建类型
type UserRole = User["role"]; // "admin" | "editor" | "viewer"

// 将属性变为可空
type NullableUser = { [K in keyof User]: User[K] | null };

// Record 类型:具有特定键和值类型的对象
type RolePermissions = Record<UserRole, string[]>;

// 从联合类型中提取/排除
type AdminOrEditor = Extract<UserRole, "admin" | "editor">; // "admin" | "editor"
type NonAdmin = Exclude<UserRole, "admin">; // "editor" | "viewer"

用泛型编写可复用的类型安全代码

泛型让你能编写适用于任意类型、同时保持完全类型安全的函数和类:

// 不使用泛型——丢失类型信息
function first(arr: any[]): any {
  return arr[0];
}

// 使用泛型——保留类型
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const num = first([1, 2, 3]);     // 类型:number | undefined
const str = first(["a", "b"]);   // 类型:string | undefined

约束泛型

// T 必须拥有 'id' 属性
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

// K 必须是 T 的键
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: "Alice", role: "admin" };
const name = getProperty(user, "name");  // 类型:string
const role = getProperty(user, "role");  // 类型:string
// getProperty(user, "missing"); // ❌ TypeScript 报错——"missing" 不是有效的键

类型守卫与类型收窄

// 类型谓词——自定义类型守卫
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value &&
    "email" in value
  );
}

// 用法
const data: unknown = await fetchUser();
if (isUser(data)) {
  console.log(data.name); // TypeScript 在此处知道 data 是 User 类型
}

对于运行时的 API 响应校验,建议将 TypeScript 接口与 Zod 的运行时 schema 验证配合使用。使用我们的 JSON to Zod 转换工具直接从 JSON 生成 Zod schema——粘贴一个示例 JSON 响应,即可立即获得类型安全的 schema。

模板字面量类型

TypeScript 4.1+ 支持在类型层面进行字符串操作:

type EventName = "click" | "focus" | "blur";
type Handler = `on${Capitalize<EventName>}`; // "onClick" | "onFocus" | "onBlur"

type CSSProperty = "margin" | "padding";
type CSSDirection = "top" | "right" | "bottom" | "left";
type CSSKey = `${CSSProperty}-${CSSDirection}`;
// "margin-top" | "margin-right" | ... | "padding-left"

satisfies 运算符(TypeScript 4.9+)

在不拓宽类型的前提下,验证某个值是否符合指定类型:

type ColorMap = Record<string, string>;

// ❌ 不使用 satisfies——类型被拓宽为 Record<string, string>
const colors: ColorMap = {
  primary: "#3b82f6",
  secondary: "#8b5cf6",
};
colors.primary.toUpperCase(); // 可以运行,但丢失了 string 字面量类型

// ✅ 使用 satisfies——既验证类型,又保留字面量类型
const colors = {
  primary: "#3b82f6",
  secondary: "#8b5cf6",
} satisfies ColorMap;

colors.primary.toUpperCase(); // ✅ TypeScript 知道它是 string 类型
colors.nonexistent; // ❌ 报错——该键不存在

从数据生成类型

无需手动根据 API 响应编写 TypeScript 接口:

  1. 将示例 JSON 响应粘贴到我们的 JSON to TypeScript 转换工具,即可立即获得接口定义
  2. 或使用 JSON to Zod 生成 Zod schema——Zod 通过 z.infer<typeof schema> 自动推断 TypeScript 类型
import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(["admin", "editor", "viewer"]),
});

// 类型自动推断——无需重复定义
type User = z.infer<typeof UserSchema>;

// 在运行时验证的同时提供 TypeScript 类型
const user = UserSchema.parse(await response.json());

应避免的常见反模式

反模式 问题 解决方案
as any 禁用类型检查 使用 unknown + 类型收窄
// @ts-ignore 屏蔽错误而非修复 修复类型错误
as SomeType(不安全强转) 绕过类型推断 使用类型守卫
interface 定义联合类型 不适用于判别联合类型 使用 type
过于宽泛的 object 无结构信息 定义具体的接口
Function 作为类型 无参数/返回值信息 使用具体的函数签名

值得了解的 TypeScript 配置项

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,  // array[n] 返回 T | undefined
    "exactOptionalPropertyTypes": true, // 区分属性缺失与值为 undefined
    "noImplicitReturns": true,          // 所有代码路径必须有返回值
    "noFallthroughCasesInSwitch": true, // 禁止 switch 中意外的 fallthrough
    "verbatimModuleSyntax": true        // 纯类型导入必须使用 import type
  }
}

TypeScript 的价值与你使用它的严格程度成正比。本文介绍的这些模式——严格模式、判别联合类型、泛型、穷举性检查——正是让 TypeScript 真正发现 Bug,而不仅仅是增加语法负担的关键所在。