错误处理类型化:Result<T,E> 模式替代 try/catch
第14章:错误处理类型化:Result<T,E> 模式替代 try/catch
理解错误处理类型化是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用Result<T,E> 模式替代 try/catch?关键的设计决策和陷阱是什么?
读完本章你将理解:
- try/catch 的类型系统盲区
- 四种错误处理方案对比
- 实现 Result<T, E>
Level 1 · 你需要知道的(1-3年经验)
try/catch 的类型系统盲区
TypeScript 4.0 之前,catch 子句的变量是隐式 any。4.0 之后变成了 unknown,这是进步——但也意味着你必须做类型收窄才能使用错误对象。更根本的问题是:函数签名里看不见错误。
// 你调用这个函数,签名告诉你它返回 User
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); // 隐形!
const user = await res.json();
if (!user) throw new Error("User not found"); // 隐形!
return user;
}
// 调用方:不读源码根本不知道会抛什么
try {
const user = await getUser("123");
} catch (e) {
// e 是 unknown,你不知道是 NetworkError? NotFoundError? ValidationError?
if (e instanceof Error) {
console.error(e.message); // 勉强能用,但失去了错误类型信息
}
}
函数签名 Promise<User> 对可能发生的错误沉默,这造成了两个后果:调用方不知道要处理什么错误;代码审查时无法从签名判断可靠性。
四种错误处理方案对比
| 方案 | 代表 | 优点 | 缺点 |
|---|---|---|---|
| 抛出异常 | Java, JS 传统 | 写法简单 | 错误类型不可见;调用方可以不处理 |
| 返回 null | JS 常见做法 | 简单 | 丢失错误原因;null 可以不检查就使用 |
返回 [error, value] 元组 |
Go 风格 | 可见 | 容易忘记检查第一个元素;两个都是 null 时语义不清 |
返回 Result<T, E> |
Rust, Haskell | 错误类型可见;编译器强制处理 | 需要辅助函数;不熟悉的人需要学习成本 |
// 方案 2:返回 null —— 错误原因丢失
async function findUser(id: string): Promise<User | null> {
// 网络失败? 用户不存在? 权限不足? 都是 null
return db.users.findById(id);
}
// 方案 3:Go 风格元组 —— 可以忘记检查
async function findUser(id: string): Promise<[Error | null, User | null]> {
try {
const user = await db.users.findById(id);
return [null, user];
} catch (e) {
return [e as Error, null];
}
}
const [err, user] = await findUser("123");
user.name; // 忘记检查 err!运行时可能崩溃,编译器不警告
实现 Result<T, E>
// 核心类型定义
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E = Error> = Ok<T> | Err<E>;
// 构造函数(辅助函数)
function ok<T>(value: T): Ok<T> {
return { ok: true, value };
}
function err<E>(error: E): Err<E> {
return { ok: false, error };
}
// 类型守卫
function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
return result.ok === true;
}
function isErr<T, E>(result: Result<T, E>): result is Err<E> {
return result.ok === false;
}
Level 2 · 它是怎么运行的(3-5年经验)
链式操作:map、flatMap、mapError
手动检查每个 result.ok 很快就会变得冗长。链式操作让 Result 的组合更流畅。
// map:成功时转换值,失败时透传错误
function map<T, U, E>(
result: Result<T, E>,
fn: (value: T) => U
): Result<U, E> {
if (result.ok) return ok(fn(result.value));
return result; // 透传 Err,不需要 as any
}
// flatMap:成功时执行可能失败的操作(单子绑定)
function flatMap<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
if (result.ok) return fn(result.value);
return result;
}
// mapError:失败时转换错误类型,成功时透传
function mapError<T, E, F>(
result: Result<T, E>,
fn: (error: E) => F
): Result<T, F> {
if (!result.ok) return err(fn(result.error));
return result;
}
// 使用示例
type ParseError = { code: "PARSE_ERROR"; raw: string };
type NetworkError = { code: "NETWORK_ERROR"; status: number };
function parseId(raw: string): Result<number, ParseError> {
const n = parseInt(raw, 10);
if (isNaN(n)) return err({ code: "PARSE_ERROR", raw });
return ok(n);
}
async function fetchUserById(id: number): Promise<Result<User, NetworkError>> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) return err({ code: "NETWORK_ERROR", status: res.status });
return ok(await res.json());
}
// 链式:parseId → fetchUserById,错误类型各自独立
async function getUser(rawId: string): Promise<Result<User, ParseError | NetworkError>> {
const idResult = parseId(rawId);
if (!idResult.ok) return idResult;
return fetchUserById(idResult.value);
}
真实案例:用户注册流程
用户注册涉及多个独立失败点:邮箱格式、密码强度、用户名是否已被占用、数据库写入。
// 定义所有可能的错误类型
type RegistrationError =
| { code: "INVALID_EMAIL"; email: string }
| { code: "WEAK_PASSWORD"; reason: string }
| { code: "USERNAME_TAKEN"; username: string }
| { code: "DATABASE_ERROR"; message: string };
interface RegistrationInput {
email: string;
password: string;
username: string;
}
interface User {
id: string;
email: string;
username: string;
}
// 每一步验证都返回 Result
function validateEmail(email: string): Result<string, RegistrationError> {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return err({ code: "INVALID_EMAIL", email });
}
return ok(email.toLowerCase());
}
function validatePassword(password: string): Result<string, RegistrationError> {
if (password.length < 8) {
return err({ code: "WEAK_PASSWORD", reason: "Password must be at least 8 characters" });
}
if (!/[A-Z]/.test(password)) {
return err({ code: "WEAK_PASSWORD", reason: "Password must contain an uppercase letter" });
}
if (!/[0-9]/.test(password)) {
return err({ code: "WEAK_PASSWORD", reason: "Password must contain a digit" });
}
return ok(password);
}
async function checkUsernameAvailable(
username: string
): Promise<Result<string, RegistrationError>> {
const existing = await db.users.findByUsername(username);
if (existing) {
return err({ code: "USERNAME_TAKEN", username });
}
return ok(username);
}
async function createUserRecord(
email: string,
password: string,
username: string
): Promise<Result<User, RegistrationError>> {
try {
const user = await db.users.create({ email, password: await hash(password), username });
return ok(user);
} catch (e) {
return err({ code: "DATABASE_ERROR", message: (e as Error).message });
}
}
// 完整注册流程:错误类型在签名里完全可见
async function registerUser(
input: RegistrationInput
): Promise<Result<User, RegistrationError>> {
const emailResult = validateEmail(input.email);
if (!emailResult.ok) return emailResult;
const passwordResult = validatePassword(input.password);
if (!passwordResult.ok) return passwordResult;
const usernameResult = await checkUsernameAvailable(input.username);
if (!usernameResult.ok) return usernameResult;
return createUserRecord(emailResult.value, input.password, usernameResult.value);
}
// 调用方:错误类型完全已知,可以精确处理
async function handleRegistration(input: RegistrationInput) {
const result = await registerUser(input);
if (!result.ok) {
switch (result.error.code) {
case "INVALID_EMAIL":
showFieldError("email", `Invalid email: ${result.error.email}`);
break;
case "WEAK_PASSWORD":
showFieldError("password", result.error.reason);
break;
case "USERNAME_TAKEN":
showFieldError("username", `"${result.error.username}" is already taken`);
break;
case "DATABASE_ERROR":
showGlobalError("Registration failed. Please try again.");
console.error(result.error.message);
break;
}
return;
}
// result.value 是 User,类型安全
redirectToDashboard(result.value.id);
}
把 try/catch 转换为 Result
对于不受你控制的 API(例如 JSON.parse、第三方库),用一个包装函数转换为 Result。
// 通用 try/catch 包装
function tryCatch<T>(fn: () => T): Result<T, Error> {
try {
return ok(fn());
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}
async function tryCatchAsync<T>(fn: () => Promise<T>): Promise<Result<T, Error>> {
try {
return ok(await fn());
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}
// 使用
const parsed = tryCatch(() => JSON.parse(rawString));
if (!parsed.ok) {
console.error("JSON parse failed:", parsed.error.message);
return;
}
const data = parsed.value; // 类型安全
// 包装 fetch
async function safeFetch(url: string): Promise<Result<Response, Error>> {
return tryCatchAsync(() => fetch(url));
}
什么时候仍然应该 throw
Result 不是万能的。以下情况应该继续抛出异常:
// 1. 编程错误(invariant 违反):永远不应该发生的情况
function getElement(index: number, arr: readonly unknown[]): unknown {
if (index < 0 || index >= arr.length) {
// 这是调用方的 bug,不是运行时的预期失败
throw new RangeError(`Index ${index} out of bounds for array of length ${arr.length}`);
}
return arr[index];
}
// 2. 构造函数内部(构造函数无法返回 Result)
class Config {
constructor(raw: unknown) {
if (!isValidConfig(raw)) {
throw new Error("Invalid config"); // 没有选择
}
}
}
// 3. 真正意外的错误(内存不足、系统崩溃)
// 这类错误无法在业务逻辑层有意义地处理,应该让它冒泡到顶层
// Result 适用:业务逻辑的预期失败路径
// throw 适用:程序错误、构造函数、不可恢复的系统错误
Level 3 · 规范怎么定义的(资深)
Result<T, E> 模式源自 Rust 和 Haskell 的错误处理设计。与 try/catch 的根本区别在于:Result 把错误变成了函数签名的一部分,编译器可以强制调用方处理错误。在范畴论中,Result 是一个 Monad——map 对应 functor 的 fmap,flatMap 对应 monadic bind。TypeScript 4.0 将 catch 变量类型改为 unknown 是向类型安全迈出的重要一步,但 throw 机制本身仍是不可类型化的——这是 Result<T, E> 模式存在的根本原因。
Level 4 · 边界与陷阱(所有人)
反模式:Result 用在实现细节里
// 反例:内部私有函数也用 Result,过于冗长
class UserParser {
private parseField(raw: unknown): Result<string, Error> {
// 这是私有实现细节,调用方就是本类,完全可以直接 throw
if (typeof raw !== "string") return err(new Error("not a string"));
return ok(raw);
}
parse(data: unknown): Result<User, Error> {
const nameResult = this.parseField((data as any).name);
if (!nameResult.ok) return nameResult;
// ...
}
}
// 正解:内部实现用 throw,公共接口用 Result
class UserParser {
private parseField(raw: unknown): string {
if (typeof raw !== "string") throw new Error("not a string");
return raw; // 简洁
}
parse(data: unknown): Result<User, Error> {
return tryCatch(() => {
// parseField 的 throw 在这里被 tryCatch 捕获并转换为 Result
return {
name: this.parseField((data as any).name),
email: this.parseField((data as any).email),
};
});
}
}
汇总
| 方案 | 错误可见性 | 强制处理 | 链式组合 | 适用范围 |
|---|---|---|---|---|
| throw | 隐形 | 否 | 否 | 编程错误、构造函数 |
T | null |
有,但丢失原因 | 否 | 差 | 简单"找不到"场景 |
[E, T] 元组 |
有 | 否 | 差 | 不推荐 |
Result<T, E> |
完整 | 是 | 优秀 | 业务逻辑失败路径 |
下一章预告
第 15 章进入 async/Promise 类型:Promise.all 的元组推导、Awaited<T> 工具类型、以及 async 函数的错误类型问题——与本章的 Result<T, E> 结合,构建完整的异步类型安全体系。