TypeScript 工程化进阶实战

TypeScript 早已不是可选的加分项,而是中大型前端项目的标配。但很多团队停留在给变量加类型注解的层面,没有发挥类型系统的真正威力。本文从 strict 模式配置讲起,深入泛型、工具类型与类型体操,最后覆盖 tsconfig 工程化配置与常见迁移坑。

TypeScript 工程化配图:蓝色渐变背景上堆叠方块带类型盾牌图标,编译器管线分层过滤,扁平科技插画
类型系统:在代码运行前就把大部分错误挡在门外

一、strict 模式:类型安全的起点

很多团队的 tsconfig 里只开了 "strict": true,但不知道 strict 其实是一组开关的合集。它同时开启了 noImplicitAny、strictNullChecks、strictFunctionTypes 等七项检查。下面是一个推荐的完整配置:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true
  }
}

noUnusedLocals 和 noUnusedParameters 会让未使用的变量和参数直接报错,这在大型项目里非常有用——它帮你删掉了大量死代码。

迁移建议:存量 JS 项目不要一上来就全开 strict。先用 // @ts-nocheck 注释老文件,然后逐个模块开启 strict。可以用 allowJs: true 让 TS 渐进式接管 JS 文件。

二、泛型:写出可复用的类型安全代码

泛型是 TypeScript 最强大也最容易让人困惑的特性。它的核心思想是:把类型也当作参数传入函数。下面是一个实际项目中常见的 API 请求封装:

// 不带泛型:返回 any,失去类型安全
function fetchUser(url: string) {
  return fetch(url).then(r => r.json());
}

// 带泛型:调用时指定返回类型
async function request<T>(url: string, options?: RequestInit): Promise<T> {
  const resp = await fetch(url, options);
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
  return resp.json() as Promise<T>;
}

interface User {
  id: number;
  name: string;
  email: string;
}

// 使用时自动获得完整类型提示
const user = await request<User>("/api/user/1");
console.log(user.name);  // 有类型提示
console.log(user.age);   // 报错:User 上不存在 age

三、工具类型:内置的类型体操

TypeScript 内置了一组工具类型,能帮你快速从已有类型派生出新类型,不需要手写重复代码。

interface User {
  id: number;
  name: string;
  email: string;
  age: number;
}

// Partial:所有属性变可选
type UserUpdate = Partial<User>;
// 等价于 { id?: number; name?: string; email?: string; age?: number; }

// Pick:只选部分属性
type UserPreview = Pick<User, "id" | "name">;
// 等价于 { id: number; name: string; }

// Omit:排除部分属性
type UserWithoutId = Omit<User, "id">;
// 等价于 { name: string; email: string; age: number; }

// Readonly:所有属性变只读
type FrozenUser = Readonly<User>;

// Record:构造键值映射类型
type UserMap = Record<number, User>;
// 等价于 { [key: number]: User }

这五个工具类型覆盖了日常开发 80% 的派生类型需求。掌握它们,你就不用再手写大量重复的 interface 了。

四、条件类型与模板字面量

当工具类型不够用时,条件类型让你能写出根据输入类型自动计算输出类型的逻辑。

// 约束 API 返回类型
type ApiResponse<T> =
  | { status: "success"; data: T }
  | { status: "error"; message: string };

function handleResponse<T>(resp: ApiResponse<T>): T | null {
  if (resp.status === "success") {
    return resp.data;
  }
  console.error(resp.message);
  return null;
}

// 模板字面量类型:根据字符串模板推导类型
type EventName = `on${Capitalize<string>}`;
// 可能是 onClick、onSubmit、onChange 等等

五、与 Vue / React 配合

现代前端框架都对 TypeScript 有很好的原生支持。Vue 3 的 <script setup lang="ts"> 和 React 的函数组件加泛型,让组件的 props 和状态都有完整类型提示。

// React 组件
interface ButtonProps {
  variant: "primary" | "danger" | "ghost";
  size?: "small" | "medium" | "large";
  onClick?: () => void;
  children: React.ReactNode;
}

function Button({ variant, size = "medium", onClick, children }: ButtonProps) {
  return (
    <button className={`btn-${variant} btn-${size}`} onClick={onClick}>
      {children}
    </button>
  );
}

// 使用时:variant 写错会立刻报错
<Button variant="primar">确定</Button>
// 类型错误:"primar" 不是合法的 variant 值

六、常见坑与调试

坑一:滥用 any。看到类型报错就写 any 是最常见的反模式——这等于放弃了 TypeScript 的所有保护。正确做法是用 unknown,在使用前再做类型收窄。

// 不好
function parse(data: any) {
  return data.user.name;
}

// 好
function parse(data: unknown) {
  if (typeof data === "object" && data !== null && "user" in data) {
    const user = (data as { user: { name: string } }).user;
    return user.name;
  }
  throw new Error("数据格式不对");
}

坑二:类型断言用太多。as Type 是在告诉编译器"相信我,这个类型是对的"。用多了就等于在类型系统上打补丁。尽量让类型自动推导,而不是手动断言。

坑三:忽略 tsconfig 里的 skipLibCheck。这个选项跳过对 node_modules 里 .d.ts 文件的类型检查,能大幅加快编译速度。但它也意味着第三方库的类型错误不会被发现,这是合理的权衡。

七、总结

TypeScript 的价值不在于"给 JS 加了类型",而在于它在代码运行之前就帮你发现了大量潜在错误。从 strict 模式起步,善用泛型和工具类型,把类型系统当成你的第一个测试套件。记住三条原则:别用 any、让类型自动推导、把 tsconfig 的严格选项拉满。