重新思考缺失:TypeScript 中 Option 类型的温和入门
Source: Dev.to
如果你在 JavaScript 或 TypeScript 上花过相当多的时间,你一定对 null 和 undefined 非常熟悉。它们是我们代码库中的暗物质——在把一切拉入运行时错误的黑洞之前,它们是看不见的。
我们使用它们是因为它们很方便。它们是语言中“nothingness”的默认状态:
- 当函数没有返回值时,它返回
undefined。 - 当我们想显式地清除一个变量时,可能会把它设为
null。
这种用法感觉很自然,因为它已经内置在语法中。
为什么便利会付出代价
便利伴随着一种微妙且逐渐累积的代价。null 和 undefined 往往被当作 值 来处理,但它们的行为更像是反值。它们破坏了我们类型的契约。如果一个变量的类型是 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 | null与string并不相同。- 可选链 (
?.) 和 空值合并 (??) – 语法糖,使处理缺失数据的代码更简洁。
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,使转换可组合 |
| “缺失”具有意义的领域逻辑 | ✅ 强制你对意义建模(None 与 Some) |
简单的内部检查,快速 if 更清晰 | ❌ 纯 guard 可能更易读 |
TL;DR
null/undefined是方便的,但会把运行时风险泄漏到整个代码库。- TypeScript 的严格模式、可选链和空值合并运算符可以减轻痛苦,但往往只是 掩盖 问题。
Option(或Maybe)模式把 缺失 作为一等概念,使运行时风险转化为编译时契约。- 像
@rsnk/option这样的库让你几乎没有摩擦地采用该模式。
通过拥抱 Option,你可以获得:
- 明确的意图 —— 每一种可能的 “无” 都被有意地处理。
- 更清晰的管道 —— 转换可以在没有嵌套 guard 的情况下组合。
- 更强的类型安全 —— 编译器强制你考虑
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 并非一蹴而就,也不是成为“好”开发者的必备条件。它只是一种工具——一种以安全性和明确性为优先的不同世界建模方式。
如果这个概念激起了你的好奇心,你 …