想象这样一种情况:你调用了一个API接口,然后得到了相应的响应数据。你将这些数据直接放入应用程序中,在开发环境中一切看起来都正常。你的模拟数据格式正确,类型也匹配得当,没有任何问题。

但当代码部署到生产环境后,却发现某个字段从API接口返回的值是null而不是字符串;本来期望得到的是一个数组,却得到了undefined;预期是一个对象,但实际上得到的是一个number。突然之间,你就会遇到错误屏幕、UI崩溃,或者更糟糕的情况——数据被悄悄地破坏了,而直到用户投诉之前,根本没有人察觉到这些问题。

这种错误在JavaScript开发中非常常见,而且是可以预防的。解决这个问题并不需要使用第三方库或对整个架构进行大规模修改,只需要一些简单的辅助函数,并且在需要的时候严格遵守这些函数的用法即可。

本文将向你介绍如何利用四种TypeScript辅助函数来构建更加健壮的应用程序,这些函数能让你的代码库变得更加可靠:safeArraysafeStringsafeNumbersafeObject。这些辅助函数与具体的开发框架无关,因此无论你是在使用React、纯JavaScript还是其他技术,都可以直接将它们添加到你的代码中。

目录

先决条件

在开始学习之前,你需要具备以下条件:

  • 对TypeScript有基本的了解。你不需要成为专家,但应该熟悉类型、接口和泛型等相关概念。

  • 熟悉JavaScript及其内置的类型检查方法,比如typeofArray.isArray

问题所在

JavaScript是一种松类型的编程语言。它允许你对非数组对象使用.map()方法,允许你访问null的属性,也允许你对NaN进行运算。所有这些操作都不会在错误发生之前被及时阻止,直到为时已晚才会出现问题。这种语言并不会给出任何提示,而是会默默地出错。

TypeScript在一定程度上可以帮助解决这些问题。它会在编译阶段检查类型,而不是运行时。但是,当外部数据通过API、表单提交、本地存储或第三方SDK传入时,TypeScript已经无法再对这些数据进行类型检查了。因此,无论你的接口定义如何,运行时实际得到的数据仍然是JavaScript所接收到的那个值。

以下是实际运行中的情况:

// 这看起来没什么问题,但实际上并非如此。
type User = {
  id: number;
  name: string;
  tags: string[];
};

function displayUser(user: User) {
  const upperName = user.name.toUpperCase();
  const tagList = user.tags.map((tag) => `${tag}`);
  return { upperName, tagList };
}

如果 user.name 的值为 null,那么调用 .toUpperCase() 会导致应用程序崩溃;而如果 user_tagsundefined,调用 .map() 也会导致程序异常终止。在使用真实的 API 时,这两种情况都很可能发生,而 TypeScript 也不会发出警告,因为你在编写代码时明确指定了这些数据的类型。

等等!你可能会说:“我可以使用‘可选链’来避免程序崩溃。” 这确实是个可行的方法,如下例所示:

// 这种写法看起来更好一些……但仍然存在问题。
type User = {
  id: number;
  name: string;
  tags: string[];
};

function displayUser(user: User) {
  const upperName = user?.name?.toUpperCase?.();
  const tagList = (user?.tags || [])?.map((tag) => `${tag}`);
  return { upperName, tagList };
}

然而,上述方法也存在一些问题。首先,如果 user.name 不是字符串类型,那么 upperName 的值将会是 undefined;其次,user?.tag || [] 这种写法只能处理 undefinednull 的情况,但如果返回的是一个对象,这种写法就会失效。现在你应该明白问题所在了吧?

因此,user?.name?.toUpperCase(). 这种写法能够安全地处理那些 username 或甚至 toUpperCase 本身可能不存在的情况。在处理数据结构不明确的情况下,这种写法确实很实用,但它无法解决数据类型不匹配的问题。

为什么会出现这个问题

问题的根源在于 JavaScript 的类型系统——或者说,它实际上并没有真正的类型系统。

JavaScript 只有一些基本的数据类型以及一些看似合理的规则,但仔细观察这些规则就会发现其中的问题。例如,typeof null 的结果是 "object"typeof [] 也是 "object",而 typeof NaN 却是 "number"。这些并不是边缘情况,它们本身就是 JavaScript 语言的规定。

下面这个例子可以清楚地说明 JavaScript 如何容易地误导开发者:

typeof null;        // "object" — 而不是 "null"
typeof [];          // "object" — 而不是 "array"
typeof NaN;         // "number" — 因为 NaN 在技术上属于数字类型
Array.isArray([]);  // true — 这是正确的判断方法
isNaN("hello");     // true — 因为 "hello" 会被强制转换为 NaN
Number.isNaN("hello"); // false — 这才是正确的判断方法

TypeScript 在 JavaScript 的基础上添加了静态类型系统,能够在代码运行之前捕获许多错误。但是,静态分析只能针对你已经编写好的代码进行。一旦数据通过网络传输过来,或者来自 localStorage、URL 参数、第三方脚本,或是代码库之外的任何来源,TypeScript 的类型检查机制就无法发挥作用了。

当你编写这样的代码时:

const data = await response.json() as User;

你实际上并没有对任何数据进行检查或验证。你只是告诉TypeScript编译器:“我保证这个变量是一个User类型”。编译器接受了这个声明,也就不再继续进行其他检查了。但是,如果API返回的是null作为某个字段的值,或者返回的字符串本应是一个数字,又或者完全省略了某个属性,那么JavaScript在执行代码时仍然会继续运行,而你的代码很可能会在那些假设数据符合预期类型的操作处出现错误。

这种“TypeScript认为数据是什么”与“程序运行时数据的实际值是什么”之间的差异,正是大多数生产环境中数据错误产生的根源。解决这个问题的方法就是不再依赖类型断言,而是自己亲自对数据进行验证。

解决方案:安全访问工具函数

正确的做法是在数据进入应用程序的那一刻就开始对其进行验证,在将其传递给其他地方之前先确保其符合预期格式。

以下这四个函数正是用来实现这一目标的:

export function safeArray(prop: unknown): T[] {
  if (Array.isArray[prop)) {
    return prop as T[];
  } else {
    return [] as T [];
  }
}

export function safeString(prop: unknown, fallback = ""): string {
  if (typeof prop === "string") {
    return prop;
  } else {
    return fallback;
  }
}

export function safeNumber[prop: unknown, fallback = 0): number {
  if (typeof prop === "number" && !isNaN[prop)) {
    return prop;
  } else {
    return fallback;
  }
}

export function safeObject(prop: unknown, fallback = {} as T): T {
  if (prop !== null && typeof prop === "object" && !Array.isArray(prop)) {
    return prop as T;
  }
  return fallback;
}

每个函数都接受一个类型为unknown的参数,这种设计强制你在使用数据之前先对其进行验证。如果输入的数据不符合预期格式,这些函数会返回一个安全的默认值,从而避免程序崩溃、出现undefined错误,或者产生难以理解的运行时异常。

你可以将这些工具函数添加到任何JavaScript或TypeScript项目中,无论是React应用、Node.js API,还是普通的TypeScript模块,只要是需要处理外部数据的地方都可以使用它们。

每个工具函数的工作原理

safeArray

export function safeArray(prop: unknown): T[] {
  if (Array.isArray[prop)) {
    return prop as T[];
  } else {
    return [] as T [];
  }
}

这个函数会使用Array.isArray来判断prop是否真的是一个数组。如果是,那么它就会以T[]类型返回这个数组;如果prop不是数组,而是nullundefined、字符串等其他类型的值,那么它就会返回一个空数组。

这种处理方式非常重要,因为JavaScript中存在这样一个特殊现象:typeof []的结果是"object",这意味着如果仅仅使用typeof来进行判断,是无法识别出[]实际上是一个数组的。而Array.isArray则能够正确地解决这个问题。

safeString

export function safeString(prop: unknown, fallback = ""): string {
  if (typeof prop === "string") {
    return prop;
  } else {
    return fallback;
  }
}

这个函数使用typeof来确认该值的类型是否为字符串。可选的fallback参数允许你指定一个有意义的默认值。例如,在显示用户姓名时,可以使用"Unknown"作为默认值,而不是空字符串。

safeNumber

export function safeNumber(prop: unknown, fallback = 0): number {
  if (typeof prop === "number" && !isNaN[prop)) {
    return prop;
  } else {
    return fallback;
  }
}

这里的关键在于!isNaN[prop)这一检查。因为在JavaScript中,typeof NaN === "number"为真,如果跳过这个检查,就可能会返回NaN,从而导致后续的计算出现错误。这个函数就是为了防止这种情况发生的。

safeObject

export function safeObject(prop: unknown, fallback = {} as T): T {
  if (prop !== null && typeof prop === "object" && !Array.isArray[prop)) {
    return prop as T;
  }
  return fallback;
}

由于JavaScript的一些特殊规定,这个函数需要满足三个条件。typeof null === "object"为真,typeof [] === "object"也为真,因此这个函数明确排除了这两种情况。这样,返回的结果肯定是一个普通的对象,而不会是其他类型。

如何在实际中使用它们

规范API响应数据(使用纯TypeScript实现)

这些工具的最佳使用场景是在处理API响应数据的函数中,在数据被传递到应用程序的其他部分之前进行相应的处理。无论是在React应用中、Node.js服务中,还是普通的TypeScript模块中,这种用法都是相同的。

// lib/users.ts
import { safeArray, safeString, safeNumber, safeObject } from 「@utils/safe";

type User = {
  id: number;
  name: string;
  email: string;
  tags: string[];
};

function normaliseUser(raw: unknown): User {
  const obj = safeObject>(raw);

  return {
    id: safeNumber(obj.id),
    name: safeString(obj.name, "Unknown User"),
    email: safeString(obj.email),
    tags: safeArray(obj.tags),
  };
}

async function fetchUser(id: string): Promise {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  return normaliseUser(data);
}

当你的代码接收到User对象时,可以确保其中的每个字段都符合你预先定义的类型。后续的处理过程就不再需要担心name字段可能是null,或者tags字段可能是undefined这类问题了。

在 React 组件中(防御性数据处理方式)

有时你会在组件中直接接收到数据,这些数据可能来自属性、上下文或查询结果,而你无法控制这些数据的初始格式。在这种情况下,应在使用数据的地方对其进行规范化处理。

import { safeArray, safeString, safeNumber, safeObject } from:「/utils/safe";

type ProductProps = {
  product: unknown;
};

function ProductCard({ product }: ProductProps) {
  const p = safeObject〈Record〈string, unknown〉〉(product);
  const name = safeString(p.name, "未命名的产品");
  const price = safeNumber(p.price);
  const tags = safeArray〈string〉>(p.tags);

  return (
    

{name}

${price.toFixed(2)}

    {tags.map((tag) => (
  • {tag}
  • ))}
); }

即使 product 的值为 null 或者是完全不符合预期的数据结构,这个组件也会显示一个默认状态,而不会发生崩溃。

使用 React Query 时

如果你正在使用 React Query,你可以在 select 组件内部对数据进行规范化处理,这样在数据到达你的组件之前,它就已经被转换成了合适的格式。

import { useQuery } from "@tanstack/react-query";
import { safeArray, safeString, safeNumber, safeObject } from:「/utils/safe";

type Order = {
  id: number;
  status: string;
  total: number;
  items: string[];
};

function normaliseOrder(raw: unknown): Order {
  const obj = safeObject〈Record〈string, unknown〉〉(raw);
  return {
    id: safeNumber(obj.id),
    status: safeString(obj.status, "pending"),
    total: safeNumber(obj.total),
    items: safeArray〈string〉>(obj.items),
  };
}

function useOrder(orderId: string) {
  return useQuery({
    queryKey: ["order", orderId],
    queryFn: () =>
      fetch(`/api/orders/${orderId`).then((res) => res.json()),
    select: normaliseOrder,
  });
}

select 回调函数会在查询结果返回之后、数据被缓存之前执行。无论 API 实际返回了什么数据,useOrder 钩子总是会返回一个格式正确的 Order 对象。

使用 React 上下文提供者时

上下文是一种机制,通过它,不安全的数据可能会在整个组件树中悄悄传播。因此,你需要在上下文提供者的层面对数据进行规范化处理,这样所有使用该上下文的组件都能得到保护。

import { createContext, useContext, useEffect, useState } from "react";
import { safeArray, safeString, safeObject } from:「/utils/safe";

type AppConfig = {
  theme: string;
  features: string[];
};

const defaultConfig: AppConfig = {
  theme: "light",
  features: [],
};

const ConfigContext = createContext〈AppConfig〉>(defaultConfig);

function ConfigProvider({ children }: { children: React.ReactNode }) {
  const [config, setConfig] = useState〈AppConfig〉>(defaultConfig);

  useEffect(() => {
    fetch("/api/config")
      .then((res) => res.json())
      .then((raw: unknown) => {
        const obj = safeObject〈Record〈string, unknown〉〉(raw);
        setConfig({
          theme: safeString(obj.theme, "light"),
          features: safeArray〈string〉>(obj.features),
        });
      });
  }, []);

  return (
    

在提供者层面进行规范化处理,能够保护所有依赖这些数据的组件。

在 Node.js API 路由中

这些工具在后端同样非常有用。如果你的 Node.js API 接收到了请求体,你无法确定客户端发送的数据是否符合预期。因此必须在数据进入系统时对其进行验证。

// routes/orders.ts (Express)
import { safeArray, safeString, safeNumber, safeObject } from "../utils/safe";

type OrderPayload = {
  userId: number;
  notes: string;
  itemIds: number[];
};

function parseOrderPayload(raw: unknown): OrderPayload {
  const obj = safeObject>(raw);
  return {
    userId: safeNumber(obj.userId),
    notes: safeString(obj.notes),
    itemIds: safeArray(obj.itemIds),
  };
}

app.post("/orders", (req, res) => {
  const payload = parseOrderPayload(req.body);

  if (!payload.userId) {
    return res.status(400).json({ error: "userId is required" });
  }

  // 继续处理经过验证的数据
});

同样是这四种工具,同样的使用模式,只是运行环境不同而已。

最佳实践

对于任何事情来说,都有一些最佳实践可以帮助你正确、高效地使用这些工具。

首先,应在数据处理的边界处进行规范化处理,而不是在每个函数内部都进行验证。最适合使用这些工具的地方是数据获取层、API 处理程序或集成点,在数据被进一步处理之前进行一次规范化处理。如果你在同一字段上在五个不同的地方使用了 safeString 函数,那就说明这种规范化处理应该放在更上游的位置。

其次,要使用有意义的默认值作为回退选项。虽然空字符串、0、空数组和空对象这些默认值是安全的,但有时也会造成误解。对于用户的显示名称来说,safeString(name, "Anonymous")safeString(name) 提供了更多有用的信息。因此,请根据你的业务需求为每个字段选择合适的默认值。

第三,要确保类型定义能够真实反映数据来源的情况。如果某个字段在数据源中确实可能为 nullundefined,那么就在类型定义中体现这一点,并使用这些工具来处理这种情况。如果将一个可能为 null 的字段定义为 string,那就只是掩盖了问题而已。只有当类型定义与实际数据情况相符时,这些工具才能发挥最佳效果。

最后,创建一个专门用于进行规范化的模块。把所有的规范化处理函数集中放在一个文件中,比如 src/lib/normalise.ts。这样可以使这些防御性逻辑得到集中管理,便于测试,同时也不会干扰到应用程序的核心逻辑。

应避免的做法

同样地,也有一些做法是应该避免的。

首先,不要用这些工具来替代合适的数据契约。如果你的整个代码库都在使用 safeString 来处理所有数据,仅仅是因为数据来源的格式不一致,那么真正的解决办法应该是制定数据契约、OpenAPI 规范、共享数据结构、使用 Zod 进行验证,或者至少为响应结果定义明确的格式。这些工具虽然可以处理一些边缘情况和运行时出现的意外问题,但无法解决系统性的混乱。

其次,千万不要省略safeObject包装器。虽然直接将数据转换为anyas any类型的转换会完全违背TypeScript的设计原则,而尝试访问unknown类型的对象的属性无论如何都会导致编译错误。因此,请先使用safeObject来解包数据,然后再安全地访问其字段。

接下来,在使用这些工具时,不要不提取中间结果就直接进行链式操作。比如safeString(safeArray(raw)[0])这样的写法虽然简洁,但阅读和调试起来会比较困难。最好是将中间结果提取出来,并存储在命名清晰的变量中。

最后,即使你能够控制数据来源,也千万不要忽略验证步骤。“我是编写了这个API,所以我知道它返回什么”这种想法在模式更新、新增可为空字段或出现未考虑到的边界情况之前可能是正确的。请相信这些工具的作用,而不是依赖自己的记忆。

额外提示:将它们组合成一个safeData辅助函数

如果你发现自己经常需要同时使用这四个工具,那么在你开始统一API响应格式之后,完全可以把它们组合成一个简洁的辅助函数。

// utils/safeData.ts
import { safeArray, safeString, safeNumber, safeObject } from "./safe";

type SafeDataAccessors = {
  string: (key: string, fallback?: string) => string;
  number: (key: string, fallback?: number) => number;
  array: (key: string) => T[];
  object: (key: string, fallback?: T) => T;
};

export function safeData(raw: unknown): SafeDataAccessors {
  const obj = safeObject>(raw);

  return {
    string: (key, fallback = "") => safeString(obj[key], fallback),
    number: (key, fallback = 0) => safeNumber(obj[key], fallback),
    array: (key: string) => safeArray(obj[key]),
    object: (key: string, fallback = {} as T) => 
      safeObject(obj[key], fallback),
  };
}

这样一来,无论你是在React钩子中、Express路由中,还是其他地方使用这些函数,代码都会更加清晰易读:

import { safeData } from "@/utils/safeData";

function normaliseUser(raw: unknown) {
  const d = safeData(raw);
  return {
    id: d.number("id"),
    name: d.string("name", "Unknown User"),
    email: d.string("email"),
    tags: d.array("tags"),
  };
}

这种抽象层设计其实并没有什么“魔法”,只是让代码更加简洁、减少重复而已。如果你的规范化处理函数过于冗长,就可以使用这个辅助函数;但如果团队认为直接调用这些工具已经足够清晰明了,那么也可以省略这个中间步骤。

结论

JavaScript宽松的类型系统以及TypeScript仅在编译时提供的类型检查机制,在每一个数据交互点都会留下安全隐患。来自API、请求体、本地存储或第三方脚本的外部数据,在运行时并不会保证其结构与你预期的格式完全匹配。而这四个工具正好填补了这一空白,帮助你消除这些潜在问题。

safeArraysafeStringsafeNumbersafeObject这些函数都可以接受unknown类型的参数,会对实际的数据类型进行验证;如果传入的值并非预期中的类型,它们会返回一个安全的替代值。这些函数可以在React组件中使用,也可以应用于Node.js的路由处理、自定义钩子、上下文提供者,以及任何其他JavaScript或TypeScript环境中——只要是在数据进入应用程序的那一刻,这些函数就能发挥作用。

这种处理方式很简单:在数据进入系统的边界处进行验证,而在系统内部则可以直接信任这些数据。一旦数据进入了代码库,就对其进行标准化处理,这样后续的所有处理流程就可以专注于自身的任务,而无需再花费精力去检测错误的数据输入了。

Comments are closed.