重新思考缺失:TypeScript 中 Option 类型的温和入门

发布: (2025年12月26日 GMT+8 02:07)
16 min read
原文: Dev.to

Source: Dev.to

如果你在 JavaScript 或 TypeScript 上花过相当多的时间,你一定对 nullundefined 非常熟悉。它们是我们代码库中的暗物质——在把一切拉入运行时错误的黑洞之前,它们是看不见的。

我们使用它们是因为它们很方便。它们是语言中“nothingness”的默认状态:

  • 当函数没有返回值时,它返回 undefined
  • 当我们想显式地清除一个变量时,可能会把它设为 null

这种用法感觉很自然,因为它已经内置在语法中。

为什么便利会付出代价

便利伴随着一种微妙且逐渐累积的代价。nullundefined 往往被当作 来处理,但它们的行为更像是反值。它们破坏了我们类型的契约。如果一个变量的类型是 string | null,在我们证明它不是 null 之前,不能把它当作 string 来使用。

问题不一定在于缺失值的存在本身,而在于它们悄然传播的方式。一个从数据库助手返回的 null 可能会沿着三层服务层和一个控制器向上渗透,最终导致前端视图组件崩溃。等到错误出现时,导致该值缺失的上下文往往已经丢失。我们只能追踪“幽灵”,试图找出链条中哪一环没有保持住。

最明显的代价 是运行时错误。
Cannot read properties of undefined 是许多调试会话的背景音乐。在一个成熟的 TypeScript 代码库中,实际崩溃(希望)是很少见的。

真正的代价 是我们被迫采取的防御性姿态。

到处都是守卫子句

if (user) {
  if (user.address) {
    if (user.address.zipCode) {
      // finally do something
    }
  }
}

我们一次又一次地编写这些逻辑。更糟的是,这带来了沉重的认知负担。每次触碰代码时,你都必须自问:

  • “这可能是 null 吗?”
  • “前一个开发者检查过 undefined 吗?”
  • “类型定义在骗我吗?”

意图的模糊性

如果一个函数返回 null,这意味着什么?

  • 记录不存在吗?
  • 数据库连接失败了吗?
  • 该值实际上是可选的?
  • 只是还没有被初始化?

null 成为一种通用的“出了问题”或“这里什么都没有”的桶,它迫使使用者去猜测意图。我们最终写出的代码看似安全,因为它满足了 TypeScript 编译器,但在语义上并不健壮。

TypeScript 给我们的东西

  • strictNullChecks – 强制我们认识到 string | nullstring 并不相同。
  • 可选链 (?.)空值合并 (??) – 语法糖,使处理缺失数据的代码更简洁。
const zip = user?.address?.zipCode ?? '00000';

这显然比嵌套的 if 语句要好。然而,可选链往往更像是一种权宜之计,而不是根本解决方案。使用 ?. 实际上相当于在说:“如果这里出错,就继续下去并返回 undefined。”

新问题:隐式传播

undefined 值会在调用栈中向上传递。我们并没有真正处理缺失,而是把它推迟了。我们没有对数据缺失的原因进行建模,只是接受它可能会缺失。

即使在严格模式下,TypeScript 仍然把缺失视为类型系统的副作用,而不是你领域逻辑中的一等公民。

Source:

替代方案:Option(或 Maybe)类型

该模式在其他语言中已存在数十年,且正逐渐在 TypeScript 社区获得关注。

核心思想

与其传递可能缺失的原始值(例如 User | null),不如传递一个始终已定义容器。容器内部有两种可能的状态:

状态含义
Some容器中持有一个值。
None容器为空。

这种思考方式的转变意义深远:

  • 在业务逻辑中消除 null 你向编译器和后续阅读代码的人表明:“这个值可能缺失,我要求在使用数据之前必须显式处理这种可能性。”
  • 强制显式解包。 你不能意外地使用 Option 中的值。必须在使用点“解包”,从而在使用时作出有意识的决定。
  • 编译时保证。 “缺失”成为编译时的保证,而不是运行时的隐患。

一个极简库:@rsnk/option

在该领域已有许多库,但 @rsnk/option 在不引入繁重学术理论或臃肿 API 的前提下,提供了核心优势。它提供一个通用类 Option;你可以将风险数据包装进它,然后使用其方法安全地转换或获取该数据。

示例 1:前端 URL 参数解析

处理查询参数是经典的前端任务。想象一下从 URL 查询字符串中读取 page 参数。该参数可能是:

  • 缺失的,
  • 非数字字符串(例如 "abc"),
  • 或者是负数。

不使用 Option

function getPageNumber(param: string | null): number {
  // 处理缺失值
  if (!param) {
    return 1;
  }

  // 尝试解析
  const parsed = parseInt(param, 10);

  // 验证解析结果
  if (Number.isNaN(parsed) || parsed <= 0) {
    return 1;
  }

  return parsed;
}

使用 Option

import O from "@rsnk/option";

function getPageNumber(param: string | null): number {
  return O.fromNullable(param)               // Option<string>
    .map(p => parseInt(p, 10))                // Option<number>
    .filter(n => !Number.isNaN(n) && n > 0)   // Option<number>
    .unwrapOr(1);                             // number
}

我们把参数当作一个流水线处理。 我们不关心具体的失败状态(缺失还是无效),只关心得到一个有效的数字或默认值。

示例 2:服务层数据获取

假设一个仓库返回的用户记录可能存在,也可能不存在。

没有 Option

async function getUserName(id: string): Promise<string> {
  const user = await db.findUserById(id); // User | null
  if (!user) {
    throw new Error("User not found");
  }
  return user.name;
}

使用 Option

import O from "@rsnk/option";

async function getUserName(id: string): Promise<string> {
  return O.fromNullable(await db.findUserById(id))
    .map(u => u.name)
    .expect("User not found"); // throws if None
}

意图很明确:“如果用户不存在,则抛出异常。” Option 强制我们在需要值的地方决定如何处理 None 情况。

何时使用 Option

情况Option 有帮助
可能返回 “nothing” 的公共 API✅ 清晰的契约(使用 Option 而非 T | null
复杂的数据流管道✅ 消除嵌套 if,使转换可组合
“缺失”具有意义的领域逻辑✅ 强制你对意义建模(NoneSome
简单的内部检查,快速 if 更清晰❌ 纯 guard 可能更易读

TL;DR

  • null / undefined 是方便的,但会把运行时风险泄漏到整个代码库。
  • TypeScript 的严格模式、可选链和空值合并运算符可以减轻痛苦,但往往只是 掩盖 问题。
  • Option(或 Maybe)模式把 缺失 作为一等概念,使运行时风险转化为编译时契约。
  • @rsnk/option 这样的库让你几乎没有摩擦地采用该模式。

通过拥抱 Option,你可以获得:

  1. 明确的意图 —— 每一种可能的 “无” 都被有意地处理。
  2. 更清晰的管道 —— 转换可以在没有嵌套 guard 的情况下组合。
  3. 更强的类型安全 —— 编译器强制你考虑 None 情况。
{
  return O.fromNullable(param)
    .map(p => parseInt(p, 10))
    .filter(p => !Number.isNaN(p) && p > 0)
    .unwrapOr(1);
}

这突显了该模式的优势:可组合性。我们将解析逻辑和验证逻辑合并到单一流程中,而无需声明临时变量或编写手动的 if 检查。

示例 2:后端环境配置

读取环境变量是后端错误的经典来源。

不使用 Option

const rawPort = process.env.PORT;
let port = 3000;

if (rawPort) {
  const parsed = parseInt(rawPort, 10);
  if (!Number.isNaN(parsed)) {
    port = parsed;
  }
}

使用 Option

import O from "@rsnk/option";

const port = O.fromNullable(process.env.PORT)
  .map(p => parseInt(p, 10))
  .filter(p => !Number.isNaN(p))
  .unwrapOr(3000);

意图非常明确。我们获取该值,尝试解析它,确保它是一个有效数字(使用 filter),如果其中任何一步失败——或者该值缺失——我们就默认使用 3000。没有临时变量,也没有嵌套的 if 块。

Source:

示例 3:前端数据转换

考虑一个获取交易列表的仪表盘。我们需要找到最新的交易,格式化其日期并显示它。数组可能为空,日期字符串可能格式错误,或者交易可能根本不存在。

import O from "@rsnk/option";

interface Transaction {
  id: string;
  timestamp?: string; // API 可能返回部分数据
  amount: number;
}

// Helper to safely get an array element
const lookup = <T>(arr: T[], index: number): O.Option<T> =>
  O.fromNullable(arr[index]);

// Helper to safely parse a date string
const dateFromISOString = (iso: string): O.Option<Date> => {
  const d = new Date(iso);
  return isNaN(d.getTime()) ? O.none : O.from(d);
};

function getLastTransactionDate(transactions: Transaction[]): string {
  return O.some(transactions)
    .andThen(txs => lookup(txs, txs.length - 1))
    .mapNullable(tx => tx.timestamp)
    .andThen(ts => dateFromISOString(ts))
    .map(date => date.toLocaleDateString())
    .unwrapOr("No recent activity");
}

在传统做法中,这个函数可能需要四五个条件判断。而这里,它是一个线性流程。andThen 允许我们链式调用可能返回 Option 的操作。如果时间戳无效,链会优雅地短路。

注意: 采用 Option 并不意味着你必须放弃 TypeScript 的原生工具,如可选链 (?.) 或空值合并运算符 (??)。事实上,它们可以很好地配合使用。

“务实” 方法

function getLastTransactionDate(transactions: Transaction[]): string {
  return O.fromNullable(transactions[transactions.length - 1]?.timestamp)
    .andThen(ts => dateFromISOString(ts))
    .map(date => date.toLocaleDateString())
    .unwrapOr("No recent activity");
}

示例 4:后端领域逻辑

让我们来看一下 Node.js 后端中的一个服务方法。我们要根据 ID 查找用户,检查他们是否拥有活跃的订阅,并返回他们的订阅等级。如果缺少任何信息,我们将其视为 “Free”(免费)层级的用户。

import O from "@rsnk/option";

interface Subscription {
  level: "pro" | "enterprise" | "basic";
  isActive: boolean;
}

interface User {
  id: string;
  subscription?: Subscription;
}

class UserService {
  private db: Map<string, User>;

  constructor(db: Map<string, User>) {
    this.db = db;
  }

  // Returns an `Option<User>`, signaling that the user might not exist.
  findUser(id: string): O.Option<User> {
    return O.fromNullable(this.db.get(id));
  }

  getUserTier(userId: string): string {
    return this.findUser(userId)
      .mapNullable(user => user.subscription)
      .filter(sub => sub.isActive)
      .map(sub => sub.level)
      .unwrapOr("free");
  }
}

此示例演示了安全导航。我们不必检查 if (user)if (user.subscription)。我们只定义了正常路径,Option 类型会自动处理异常路径。

为什么采用 Option 模式?

  • 叙事式代码: 与其使用一系列 if … else 语句,不如阅读一个连续的故事——“获取用户,查找他们的订阅,检查是否激活,获取等级。”

  • 更安全的重构: 当函数的返回类型从 T | null 改为 Option 时,TypeScript 会强制你更新所有调用点。编译器变成了更严格、更有帮助的配对编程伙伴。

  • 显式缺失处理:null | undefined 的世界里,处理缺失情况常常是事后才考虑的。使用 Option,你从第一行起就意识到“盒子”的存在,以缺失为前提设计 API,进而产生更健壮的接口。

是 Option 万能弹药吗? 不是。

和任何模式一样,上下文很重要

  • 如果你在编写一个小的、一次性的脚本,引入 Option 库可能显得大材小用。标准的可选链(?.)已经足够应对作用域小、逻辑线性简单的局部变量。
  • 存在互操作成本。如果你大量使用 React 表单或第三方库,而这些库期望的是 null,你会发现自己频繁地进行包装和解包装。虽然 @rsnk/option 很轻量,但它仍然是一个抽象层。
  • 对于领域逻辑、复杂数据处理以及共享库来说,Option 带来的好处通常会超过少量的设置成本。

何时采用 Option

  • 领域层代码——安全性和显式性至关重要。
  • 复杂的数据管道——涉及大量可空值。
  • 共享工具库——将在多个模块或项目中使用。

何时坚持使用普通 null / 可选链

  • 小脚本或一次性工具。
  • 代码库大量依赖于期望 null 的 API。
  • 抽象层会带来更多摩擦而非价值的情况。

如何开始

摆脱 null 并非一蹴而就,也不是成为“好”开发者的必备条件。它只是一种工具——一种以安全性和明确性为优先的不同世界建模方式。

如果这个概念激起了你的好奇心,你 …

Back to Blog

相关文章

阅读更多 »

在 Nest.js 中设置 MonoRepo

Monorepos 与 Nest.js Monorepos 正在成为管理多个服务或共享库的后端团队的默认选择。Nest.js 的表现非常出色……