首页 / 文章

替代 TypeScript 的 `any`:六种常见场景的类型安全方案

学习 TypeScript 中 `any` 的实用且类型安全的替代方案——包括 unknown、泛型、区分联合以及穷尽检查——以便处理不可预测的数据。

发布者:REACTAPP.TOP阅读约 8 分钟1834 词

来源: Stop Using ‘any’ in TypeScript — Here’s the Right Fix Every Time

一个小小的习惯改变,助你避免生产环境中的意外

每位 TypeScript 开发者都有过这样的经历:周五下午临近下班时,一个奇怪的编译错误阻碍了你的拉取请求提交;在花了二十分钟试图解决嵌套泛型和联合类型的问题后,你只希望尽快发布代码并关上笔记本电脑。

于是你找到了那个能解决一切问题却又似乎什么都解决不了的三个字母:

const payload: any = response.data;

红色的下划线消失了,构建过程显示为绿色,一切看起来都很正常。

但这并不好。使用 any 并不能解决类型相关的问题——它只是告诉 TypeScript 停止关注该类型。你实际上关闭了最初促使你选择 TypeScript 的那层安全保障。每当出现 any 时,都会在你的类型覆盖中留下一个漏洞,而这个漏洞会悄悄蔓延到所有与之关联的函数和变量中。

好消息是,你几乎从来都不真正需要使用它。下面将解释为何 any 会带来如此大的危害,随后还会介绍在开发者最常使用它的场景下经过验证的替代方案。

any 的真实含义(以及为何会适得其反)

许多开发者认为 any 只是意味着“该值的类型尚不清楚”。

它实际上向编译器传达的信息是:“停止检查这个变量,相信我之后对它的所有操作都是正确的。”

let user: any = { name: "Alice" };

// TypeScript allows all of these without a single warning:
user.authenticate();       // Runtime crash: user.authenticate is not a function
user.address.postalCode;   // Runtime crash: Cannot read properties of undefined
user();                    // Runtime crash: user is not a function

更糟糕的是any类型的传播性。一旦函数的返回类型是any,所有捕获该返回值的变量也会变成any类型。将该值传递给另一个函数后,安全性的缺失会持续蔓延。不久之后,你的代码库中的大部分内容实际上都只是带有.ts扩展名的普通JavaScript代码。

以下内容将针对any类型容易出现的各种常见情况,介绍正确的替代方案。

修复方法1:当类型确实还未知时(unknown)

情况:你正在从外部 API 获取数据,解析从本地存储中取出的 JSON 字符串,或处理来自 webhook 的原始数据。在这些情况下,在程序实际运行之前无法知道数据的准确结构。

有问题的处理方式:

function parseApiResponse(jsonString: string): any {
  return JSON.parse(jsonString);
}

const data = parseApiResponse('{"id": 101}');
console.log(data.profile.avatar); // Compiles fine, crashes immediately if profile is missing

正确的解决方法:使用 unknown 结合 type guard。

unknown 可以视为 any 的更安全版本。它告诉编译器:“这个值的类型尚未确定,在证明其实际类型之前,不允许访问其属性、调用方法或对其执行其他操作。”

interface UserProfile {
  id: number;
  name: string;
}

// Custom type guard
function isUserProfile(value: unknown): value is UserProfile {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    typeof (value as Record<string, unknown>).id === "number" &&
    "name" in value &&
    typeof (value as Record<string, unknown>).name === "string"
  );
}

function parseApiResponse(jsonString: string): unknown {
  return JSON.parse(jsonString);
}

const rawData = parseApiResponse('{"id": 101, "name": "Sarah"}');

// rawData.name; // Error: Object is of type 'unknown'.

if (isUserProfile(rawData)) {
  // Inside this block, TypeScript knows rawData is definitely UserProfile
  console.log(rawData.name); // Fully typed, autocompleted, and safe!
}

如果为复杂的负载手动编写校验代码显得过于繁琐,你可以使用诸如 Zod 或 Valibot 这样的运行时验证库。这些库能够一次性对未知输入进行校验,并为你生成对应的 TypeScript 类型。

解决方案 2:当一个函数需要支持多种类型时(泛型)

情况:你正在开发一个可重用的工具函数——比如缓存封装器、数组处理工具,或是 API 客户端中的方法——这类函数需要能够处理多种不同的数据结构。

有问题的做法:

function getFirstItem(items: any[]): any {
  return items[0];
}

const numbers = [1, 2, 3];
const firstNum = getFirstItem(numbers); // firstNum is typed as 'any'
firstNum.toUpperCase(); // Compiles! Crashes at runtime because it's a number.

正确的解决方案:使用 泛型(即 <T> 语法)。

泛型更像是类型的占位符而非具体值。与any不同,泛型不会丢弃类型信息,而是会记住调用者提供的类型,并将其一直保留到函数返回结果时。

function getFirstItem<T>(items: T[]): T | undefined {
  return items[0];
}

const numbers = [1, 2, 3];
const firstNum = getFirstItem(numbers);
// TypeScript automatically infers firstNum as: number | undefined

const names = ["Alice", "Bob"];
const firstName = getFirstItem(names);
// TypeScript automatically infers firstName as: string | undefined

你还可以通过使用extends来限制泛型的类型,确保该类型具备某些必需的属性:

interface HasId {
  id: string | number;
}

function printId<T extends HasId>(entity: T): void {
  console.log(entity.id);
}

修复方案3:在try/catch块中处理错误

情况:你在catch块中编写逻辑以处理出现的错误。

有问题的做法:

try {
  fetchUserData();
} catch (err: any) {
  // Risky: In JavaScript, anything can be thrown (strings, numbers, null)
  console.error(err.message.toUpperCase());
}

这里的风险在于,JavaScript允许抛出任何类型的值——字符串、数字、null、普通对象,而不仅仅是Error实例。如果某个库或意外错误抛出了普通字符串,比如throw "Network timeout",那么err.message将会是undefined,在对它调用如.toUpperCase()之类的方法时,就可能在本应处理错误的代码中引发新的、未被处理的错误。

正确的解决方法:将捕获到的错误类型定义为unknown,然后通过instanceof Error来进一步筛选。

try {
  fetchUserData();
} catch (err: unknown) {
  if (err instanceof Error) {
    // TypeScript safely knows 'err' has a .message property
    console.error(`Error: ${err.message}`);
  } else {
    // Fallback for non-standard thrown objects
    console.error("An unexpected error occurred", String(err));
  }
}

解决方案4:动态对象与基于键的查找

情况:你需要一个行为类似映射表或字典的对象,其键只有在程序运行时才会确定。

有问题的方法:

const userRolePermissions: any = {};
userRolePermissions["admin"] = ["read", "write", "delete"];

正确的修复方式:使用Record,或者采用带索引的签名。

type Role = "admin" | "editor" | "viewer";
type Permission = "read" | "write" | "delete";

// A strictly defined key-value mapping
const rolePermissions: Record<Role, Permission[]> = {
  admin: ["read", "write", "delete"],
  editor: ["read", "write"],
  viewer: ["read"],
};

// If the keys can be arbitrary dynamic strings:
const sessionCache: Record<string, unknown> = {};

使用Record可以在保持对象结构可检测的同时,强制任何使用该对象的程序在操作之前先验证其值的实际类型。

修复方案5:明确说明会改变结构的类型(区分联合体)

情况描述:某个进程会经历不同的状态——比如空闲、加载中、成功和错误——而每个状态都对应不同类型的数据。

有问题的方法:

interface RequestState {
  status: any;
  data?: any;
  error?: any;
}

如果用单一的松散接口来建模,那么每个属性都会变成可选的,迫使你在代码中处处使用 ?. 语法。更糟糕的是,这无法防止出现一种荒谬的情况:即 data 和 error 同时被赋值。

正确的解决方案:采用区分式联合类型。

为每种可能的状态设置一个通用的字面量字段——通常命名为 status 或 type——这样 TypeScript 就可以利用它来区分不同的状态:

type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; error: Error };

function renderUI(state: RequestState) {
  switch (state.status) {
    case "idle":
      return "Click start";
    case "loading":
      return "Loading...";
    case "success":
      // TypeScript automatically knows state.data exists here!
      return `Loaded ${state.data.length} items`;
    case "error":
      // TypeScript automatically knows state.error exists here!
      return `Failed: ${state.error.message}`;
  }
}

解决方案6:使用 never 进行全面检查

情况描述:你希望当团队成员为联合类型添加新分支却忘记更新所有处理该分支的 switch 或 if/else 语句时,编译器能自动发出警告。

有问题的处理方式:使用通用的 default: return any,或者根本不处理额外的情况。

正确的修复方法:将剩余的值赋给 never 类型。

never 表示在逻辑上不可能出现的值。一旦 switch 语句覆盖了联合类型中的所有成员,default 分支中剩余的值就会被限定为 never。如果后来有人为该联合类型添加新的成员,编译器会立即在该行标出错误,因为剩余的值不再符合 never 的定义:

type NotificationType = "email" | "sms" | "push";

function sendNotification(type: NotificationType) {
  switch (type) {
    case "email":
      // Handle email
      break;
    case "sms":
      // Handle SMS
      break;
    case "push":
      // Handle push notification
      break;
    default: {
      // If someone adds "in_app" to NotificationType and forgets to update this function,
      // TypeScript will raise a compile-time error right here.
      const _exhaustiveCheck: never = type;
      return _exhaustiveCheck;
    }
  }
}

快速参考:替换 any

如何在整个团队中统一规范

仅仅理解这些替换方案只能解决一半问题——另一半则是要防止在截止日期临近时仍有任何代码偷偷混入。

应将此限制直接嵌入项目配置中,以便自动执行:

在tsconfig.json中:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true
  }
}

在您的ESLint配置文件(.eslintrc.json)中:

{
  "rules": {
    "@typescript-eslint/no-explicit-any": "error"
  }
}

TypeScript 的核心价值就在于赋予开发者信心——在代码重构时、部署时都能保持信心,同时其编辑器自动补全功能还能避免开发者频繁查阅文档。刚开始使用时,去掉 any 类型可能会让人感觉有些受限。但一旦用 unknown、泛型和区分联合类型取而代之,代码就会变得更为自解释,运行时错误会大幅减少,编译器也能真正发挥其作为防护机制的作用。