name: effect-patterns-domain-modeling description: Effect-TS 模式用于域建模。在 Effect-TS 应用中处理域建模时使用。
Effect-TS 模式:域建模
此技能提供了 15 个精选的 Effect-TS 模式用于域建模。 在处理以下相关任务时使用此技能:
- 域建模
- Effect-TS 应用中的最佳实践
- 现实世界的模式和解决方案
🟢 初学者模式
创建类型安全错误
规则: 使用 Data.TaggedError 为您的域创建类型化、可区分的错误。
好例子:
import { Effect, Data } from "effect"
// ============================================
// 1. 为您的域定义标记错误
// ============================================
class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{
readonly userId: string
}> {}
class InvalidEmailError extends Data.TaggedError("InvalidEmailError")<{
readonly email: string
readonly reason: string
}> {}
class DuplicateUserError extends Data.TaggedError("DuplicateUserError")<{
readonly email: string
}> {}
// ============================================
// 2. 在 Effect 函数中使用
// ============================================
interface User {
id: string
email: string
name: string
}
const validateEmail = (email: string): Effect.Effect<string, InvalidEmailError> => {
if (!email.includes("@")) {
return Effect.fail(new InvalidEmailError({
email,
reason: "缺少 @ 符号"
}))
}
return Effect.succeed(email)
}
const findUser = (id: string): Effect.Effect<User, UserNotFoundError> => {
// 模拟数据库查找
if (id === "123") {
return Effect.succeed({ id, email: "alice@example.com", name: "Alice" })
}
return Effect.fail(new UserNotFoundError({ userId: id }))
}
const createUser = (
email: string,
name: string
): Effect.Effect<User, InvalidEmailError | DuplicateUserError> =>
Effect.gen(function* () {
const validEmail = yield* validateEmail(email)
// 模拟重复检查
if (validEmail === "taken@example.com") {
return yield* Effect.fail(new DuplicateUserError({ email: validEmail }))
}
return {
id: crypto.randomUUID(),
email: validEmail,
name,
}
})
// ============================================
// 3. 按标记处理错误
// ============================================
const program = createUser("alice@example.com", "Alice").pipe(
Effect.catchTag("InvalidEmailError", (error) =>
Effect.succeed({
id: "fallback",
email: "default@example.com",
name: `${error.email} 无效:${error.reason}`,
})
),
Effect.catchTag("DuplicateUserError", (error) =>
Effect.fail(new Error(`邮箱 ${error.email} 已注册`))
)
)
// ============================================
// 4. 匹配所有错误
// ============================================
const handleAllErrors = createUser("bad-email", "Bob").pipe(
Effect.catchTags({
InvalidEmailError: (e) => Effect.succeed(`无效:${e.reason}`),
DuplicateUserError: (e) => Effect.succeed(`重复:${e.email}`),
})
)
// ============================================
// 5. 运行并查看结果
// ============================================
Effect.runPromise(program)
.then((user) => console.log("创建:", user))
.catch((error) => console.error("失败:", error))
理由:
使用 Data.TaggedError 创建域特定错误。每个错误类型都有一个唯一的 _tag 用于模式匹配。
普通的 Error 或字符串消息会导致问题:
- 无类型安全 - 无法知道函数可能抛出什么错误
- 难以处理 - 匹配错误消息是脆弱的
- 文档差 - 错误不是函数签名的一部分
标记错误通过使错误类型化和可区分来解决此问题。
使用 Option 处理缺失值
规则: 使用 Option 而不是 null/undefined 来使缺失值显式和类型安全。
好例子:
import { Option, Effect } from "effect"
// ============================================
// 1. 创建 Option
// ============================================
// Some - 值存在
const hasValue = Option.some(42)
// None - 无值
const noValue = Option.none<number>()
// 从可空值转换 - null/undefined 变为 None
const fromNull = Option.fromNullable(null) // None
const fromValue = Option.fromNullable("hello") // Some("hello")
// ============================================
// 2. 检查和提取值
// ============================================
const maybeUser = Option.some({ name: "Alice", age: 30 })
// 检查值是否存在
if (Option.isSome(maybeUser)) {
console.log(`用户:${maybeUser.value.name}`)
}
// 获取带默认值
const name = Option.getOrElse(
Option.map(maybeUser, u => u.name),
() => "匿名"
)
// ============================================
// 3. 转换 Option
// ============================================
const maybeNumber = Option.some(5)
// Map - 如果存在则转换值
const doubled = Option.map(maybeNumber, n => n * 2) // Some(10)
// FlatMap - 链式操作返回 Option
const safeDivide = (a: number, b: number): Option.Option<number> =>
b === 0 ? Option.none() : Option.some(a / b)
const result = Option.flatMap(maybeNumber, n => safeDivide(10, n)) // Some(2)
// ============================================
// 4. 域建模示例
// ============================================
interface User {
readonly id: string
readonly name: string
readonly email: Option.Option<string> // 邮箱可选
readonly phone: Option.Option<string> // 电话可选
}
const createUser = (name: string): User => ({
id: crypto.randomUUID(),
name,
email: Option.none(),
phone: Option.none(),
})
const addEmail = (user: User, email: string): User => ({
...user,
email: Option.some(email),
})
const getContactInfo = (user: User): string => {
const email = Option.getOrElse(user.email, () => "无邮箱")
const phone = Option.getOrElse(user.phone, () => "无电话")
return `${user.name}:${email}, ${phone}`
}
// ============================================
// 5. 在 Effects 中使用
// ============================================
const findUser = (id: string): Effect.Effect<Option.Option<User>> =>
Effect.succeed(
id === "123"
? Option.some({ id, name: "Alice", email: Option.none(), phone: Option.none() })
: Option.none()
)
const program = Effect.gen(function* () {
const maybeUser = yield* findUser("123")
if (Option.isSome(maybeUser)) {
yield* Effect.log(`找到:${maybeUser.value.name}`)
} else {
yield* Effect.log("用户未找到")
}
})
Effect.runPromise(program)
理由:
使用 Option<A> 表示可能缺失的值。这使得“可能不存在”在类型中显式,强制您处理两种情况。
null 和 undefined 导致错误,因为:
- 静默失败 - 在 null 上访问
.property在运行时崩溃 - 意图不明确 - null 是“未找到”还是“错误”?
- 忘记检查 - 容易忘记
if (x !== null)
Option 通过使缺失显式和类型检查来修复此问题。
您的第一个域模型
规则: 通过为业务实体定义清晰的接口来开始域建模。
好例子:
import { Effect } from "effect"
// ============================================
// 1. 将域实体定义为接口
// ============================================
interface User {
readonly id: string
readonly email: string
readonly name: string
readonly createdAt: Date
}
interface Product {
readonly sku: string
readonly name: string
readonly price: number
readonly inStock: boolean
}
interface Order {
readonly id: string
readonly userId: string
readonly items: ReadonlyArray<OrderItem>
readonly total: number
readonly status: OrderStatus
}
interface OrderItem {
readonly productSku: string
readonly quantity: number
readonly unitPrice: number
}
type OrderStatus = "pending" | "confirmed" | "shipped" | "delivered"
// ============================================
// 2. 创建域函数
// ============================================
const createUser = (email: string, name: string): User => ({
id: crypto.randomUUID(),
email,
name,
createdAt: new Date(),
})
const calculateOrderTotal = (items: ReadonlyArray<OrderItem>): number =>
items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0)
// ============================================
// 3. 在 Effect 程序中使用
// ============================================
const program = Effect.gen(function* () {
const user = createUser("alice@example.com", "Alice")
yield* Effect.log(`创建用户:${user.name}`)
const items: OrderItem[] = [
{ productSku: "WIDGET-001", quantity: 2, unitPrice: 29.99 },
{ productSku: "GADGET-002", quantity: 1, unitPrice: 49.99 },
]
const order: Order = {
id: crypto.randomUUID(),
userId: user.id,
items,
total: calculateOrderTotal(items),
status: "pending",
}
yield* Effect.log(`订单总计:$${order.total.toFixed(2)}`)
return order
})
Effect.runPromise(program)
理由:
首先定义 TypeScript 接口来表示您的业务实体。使用匹配域语言的描述性名称。
良好的域建模:
- 澄清意图 - 类型记录数据的含义
- 防止错误 - 编译器捕获错误的数据使用
- 启用工具 - IDE 自动完成和重构
- 沟通 - 代码成为文档
🟡 中级模式
使用 Option 安全地建模可选值
规则: 使用 Option<A> 显式地建模可能缺失的值,避免 null 或 undefined。
好例子:
在数据库中查找用户的函数是典型用例。它可能找到用户,也可能找不到。返回 Option<User> 使此合约显式和安全。
import { Effect, Option } from "effect";
interface User {
id: number;
name: string;
}
const users: User[] = [
{ id: 1, name: "Paul" },
{ id: 2, name: "Alex" },
];
// 此函数安全地返回 Option,而不是 User 或 null。
const findUserById = (id: number): Option.Option<User> => {
const user = users.find((u) => u.id === id);
return Option.fromNullable(user); // 现有 API 的有用辅助函数
};
// 调用者必须处理两种情况。
const greeting = (id: number): string =>
findUserById(id).pipe(
Option.match({
onNone: () => "用户未找到。",
onSome: (user) => `欢迎,${user.name}!`,
})
);
const program = Effect.gen(function* () {
yield* Effect.log(greeting(1)); // "欢迎,Paul!"
yield* Effect.log(greeting(3)); // "用户未找到。"
});
Effect.runPromise(program);
反模式:
反模式是返回可空类型(例如,User | null 或 User | undefined)。这依赖于每个调用者执行 null 检查的纪律。忘记一个检查就可能引入运行时错误。
interface User {
id: number;
name: string;
}
const users: User[] = [{ id: 1, name: "Paul" }];
// ❌ 错误:此函数的返回类型较不安全。
const findUserUnsafely = (id: number): User | undefined => {
return users.find((u) => u.id === id);
};
const user = findUserUnsafely(3);
// 这将抛出 "TypeError: Cannot read properties of undefined (reading 'name')"
// 因为调用者忘记检查用户是否存在。
console.log(`用户的名字是 ${user.name}`);
理由:
使用 Option<A> 表示可能缺失的值。使用 Option.some(value) 表示存在的值,Option.none() 表示缺失的值。这创建了一个强制您处理两种可能性的容器。
可以返回值或 null/undefined 的函数是 TypeScript 中运行时错误(Cannot read properties of null)的主要来源。
Option 类型通过使缺失值的可能性在类型系统中显式来解决此问题。返回 Option<User> 的函数不能被误认为是返回 User 的函数。编译器强制您在访问 Some 内部的值之前处理 None 情况,消除了整个类别的错误。
使用 Effect.gen 处理业务逻辑
规则: 使用 Effect.gen 处理业务逻辑。
好例子:
import { Effect } from "effect";
// 用于演示的具体实现
const validateUser = (
data: any
): Effect.Effect<{ email: string; password: string }, Error, never> =>
Effect.gen(function* () {
yield* Effect.logInfo(`验证用户数据:${JSON.stringify(data)}`);
if (!data.email || !data.password) {
return yield* Effect.fail(new Error("邮箱和密码是必需的"));
}
if (data.password.length < 6) {
return yield* Effect.fail(
new Error("密码必须至少 6 个字符")
);
}
yield* Effect.logInfo("✅ 用户数据验证成功");
return { email: data.email, password: data.password };
});
const hashPassword = (pw: string): Effect.Effect<string, never, never> =>
Effect.gen(function* () {
yield* Effect.logInfo("哈希密码...");
// 模拟密码哈希
const timestamp = yield* Effect.sync(() => Date.now());
const hashed = `hashed_${pw}_${timestamp}`;
yield* Effect.logInfo("✅ 密码哈希成功");
return hashed;
});
const dbCreateUser = (data: {
email: string;
password: string;
}): Effect.Effect<{ id: number; email: string }, never, never> =>
Effect.gen(function* () {
yield* Effect.logInfo(`在数据库中创建用户:${data.email}`);
// 模拟数据库操作
const user = { id: Math.floor(Math.random() * 1000), email: data.email };
yield* Effect.logInfo(`✅ 用户创建,ID:${user.id}`);
return user;
});
const createUser = (
userData: any
): Effect.Effect<{ id: number; email: string }, Error, never> =>
Effect.gen(function* () {
const validated = yield* validateUser(userData);
const hashed = yield* hashPassword(validated.password);
return yield* dbCreateUser({ ...validated, password: hashed });
});
// 演示使用 Effect.gen 处理业务逻辑
const program = Effect.gen(function* () {
yield* Effect.logInfo("=== 使用 Effect.gen 处理业务逻辑演示 ===");
// 示例 1:成功创建用户
yield* Effect.logInfo("
1. 创建有效用户:");
const validUser = yield* createUser({
email: "paul@example.com",
password: "securepassword123",
}).pipe(
Effect.catchAll((error) =>
Effect.gen(function* () {
yield* Effect.logError(`创建用户失败:${error.message}`);
return { id: -1, email: "error" };
})
)
);
yield* Effect.logInfo(`创建用户:${JSON.stringify(validUser)}`);
// 示例 2:无效用户数据
yield* Effect.logInfo("
2. 尝试用无效数据创建用户:");
const invalidUser = yield* createUser({
email: "invalid@example.com",
password: "123", // 太短
}).pipe(
Effect.catchAll((error) =>
Effect.gen(function* () {
yield* Effect.logError(`创建用户失败:${error.message}`);
return { id: -1, email: "error" };
})
)
);
yield* Effect.logInfo(`结果:${JSON.stringify(invalidUser)}`);
yield* Effect.logInfo("
✅ 业务逻辑演示完成!");
});
Effect.runPromise(program);
解释:
Effect.gen 允许您以清晰、顺序的风格表达业务逻辑,提高可维护性。
反模式:
对多步业务逻辑使用长链的 .andThen 或 .flatMap。这更难阅读,并且在步骤之间传递状态更困难。
理由:
使用 Effect.gen 编写核心业务逻辑,特别是当涉及多个顺序步骤或条件分支时。
生成器提供了一种类似于标准同步代码(async/await)的语法,使复杂工作流显著更容易阅读、编写和调试。
在验证过程中使用 Schema.transform 转换数据
规则: 使用 Schema.transform 在验证和解析过程中安全地转换数据类型。
好例子:
此模式解析字符串但产生 Date 对象,使最终数据结构更有用。
import { Schema, Effect } from "effect";
// 定义类型以提高类型安全性
type RawEvent = {
name: string;
timestamp: string;
};
type ParsedEvent = {
name: string;
timestamp: Date;
};
// 定义事件模式
const ApiEventSchema = Schema.Struct({
name: Schema.String,
timestamp: Schema.String,
});
// 示例输入
const rawInput: RawEvent = {
name: "用户登录",
timestamp: "2025-06-22T20:08:42.000Z",
};
// 解析和转换
const program = Effect.gen(function* () {
const parsed = yield* Schema.decode(ApiEventSchema)(rawInput);
return {
name: parsed.name,
timestamp: new Date(parsed.timestamp),
} as ParsedEvent;
});
const programWithLogging = Effect.gen(function* () {
try {
const event = yield* program;
yield* Effect.log(`事件年份:${event.timestamp.getFullYear()}`);
yield* Effect.log(`完整事件:${JSON.stringify(event, null, 2)}`);
return event;
} catch (error) {
yield* Effect.logError(`解析事件失败:${error}`);
throw error;
}
}).pipe(
Effect.catchAll((error) =>
Effect.gen(function* () {
yield* Effect.logError(`程序错误:${error}`);
return null;
})
)
);
Effect.runPromise(programWithLogging);
transformOrFail 非常适合创建品牌类型,因为验证可能失败。
import { Schema, Effect, Brand, Either } from "effect";
type Email = string & Brand.Brand<"Email">;
const Email = Schema.string.pipe(
Schema.transformOrFail(
Schema.brand<Email>("Email"),
(s, _, ast) =>
s.includes("@")
? Either.right(s as Email)
: Either.left(Schema.ParseError.create(ast, "无效的邮箱格式")),
(email) => Either.right(email)
)
);
const result = Schema.decode(Email)("paul@example.com"); // 成功
const errorResult = Schema.decode(Email)("invalid-email"); // 失败
反模式:
在两个独立步骤中执行验证和转换。这更冗长,需要创建中间类型,并将验证逻辑与转换逻辑分离。
import { Schema, Effect } from "effect";
// ❌ 错误:需要中间“Raw”类型。
const RawApiEventSchema = Schema.Struct({
name: Schema.String,
timestamp: Schema.String,
});
const rawInput = { name: "用户登录", timestamp: "2025-06-22T20:08:42.000Z" };
// 逻辑现在分成两个不同的、凝聚力较差的步骤。
const program = Schema.decode(RawApiEventSchema)(rawInput).pipe(
Effect.map((rawEvent) => ({
...rawEvent,
timestamp: new Date(rawEvent.timestamp), // 解析后手动转换。
}))
);
理由:
要在验证过程中将数据从一种类型转换为另一种类型,使用 Schema.transform。这允许您定义解析输入类型(例如,string)并输出不同的、更丰富的域类型(例如,Date)的模式。
通常,从外部源(如 API)接收的数据不是应用程序域模型的理想格式。例如,日期作为 ISO 字符串发送,但您希望使用 Date 对象。
Schema.transform 将此转换直接集成到解析步骤中。它接受两个函数:一个用于将输入类型 decode 为域类型,另一个用于将其 encode 回去。这使得您的模式成为数据形状和类型转换的单一事实来源。
对于可能失败的转换(如创建品牌类型),可以使用 Schema.transformOrFail,它允许解码步骤返回 Either。
使用 Data.TaggedError 定义类型安全错误
规则: 使用 Data.TaggedError 定义类型安全错误。
好例子:
import { Data, Effect } from "effect";
// 定义我们的标记错误类型
class DatabaseError extends Data.TaggedError("DatabaseError")<{
readonly cause: unknown;
}> {}
// 模拟数据库错误的函数
const findUser = (
id: number
): Effect.Effect<{ id: number; name: string }, DatabaseError> =>
Effect.gen(function* () {
if (id < 0) {
return yield* Effect.fail(new DatabaseError({ cause: "无效的 ID" }));
}
return { id, name: `用户 ${id}` };
});
// 创建演示错误处理的程序
const program = Effect.gen(function* () {
// 尝试查找有效用户
yield* Effect.logInfo("查找用户 1...");
yield* Effect.gen(function* () {
const user = yield* findUser(1);
yield* Effect.logInfo(`找到用户:${JSON.stringify(user)}`);
}).pipe(
Effect.catchAll((error) =>
Effect.logInfo(`查找用户错误:${error._tag} - ${error.cause}`)
)
);
// 尝试查找无效用户
yield* Effect.logInfo("
查找用户 -1...");
yield* Effect.gen(function* () {
const user = yield* findUser(-1);
yield* Effect.logInfo(`找到用户:${JSON.stringify(user)}`);
}).pipe(
Effect.catchTag("DatabaseError", (error) =>
Effect.logInfo(`数据库错误:${error._tag} - ${error.cause}`)
)
);
});
// 运行程序
Effect.runPromise(program);
解释:
标记错误允许您以类型安全、自文档化的方式处理错误。
反模式:
在错误通道中使用通用的 Error 对象或字符串。这会丢失所有类型信息,强制使用者使用 catchAll 并执行不安全检查。
理由:
对于应用程序中的任何不同失败模式,定义一个扩展 Data.TaggedError 的自定义错误类。
这为每个错误提供唯一的字面 _tag,Effect 可以与 Effect.catchTag 一起用于类型区分,使错误处理完全类型安全。
使用 Schema 提前定义合约
规则: 使用 Schema 提前定义合约。
好例子:
import { Schema, Effect, Data } from "effect";
// 定义用户模式和类型
const UserSchema = Schema.Struct({
id: Schema.Number,
name: Schema.String,
});
type User = Schema.Schema.Type<typeof UserSchema>;
// 定义错误类型
class UserNotFound extends Data.TaggedError("UserNotFound")<{
readonly id: number;
}> {}
// 创建数据库服务实现
export class Database extends Effect.Service<Database>()("Database", {
sync: () => ({
getUser: (id: number) =>
id === 1
? Effect.succeed({ id: 1, name: "John" })
: Effect.fail(new UserNotFound({ id })),
}),
}) {}
// 创建演示模式和错误处理的程序
const program = Effect.gen(function* () {
const db = yield* Database;
// 尝试获取现有用户
yield* Effect.logInfo("查找用户 1...");
const user1 = yield* db.getUser(1);
yield* Effect.logInfo(`找到用户:${JSON.stringify(user1)}`);
// 尝试获取不存在的用户
yield* Effect.logInfo("
查找用户 999...");
yield* Effect.logInfo("尝试获取用户 999...");
yield* Effect.gen(function* () {
const user = yield* db.getUser(999);
yield* Effect.logInfo(`找到用户:${JSON.stringify(user)}`);
}).pipe(
Effect.catchAll((error) => {
if (error instanceof UserNotFound) {
return Effect.logInfo(`错误:未找到 ID 为 ${error.id} 的用户`);
}
return Effect.logInfo(`意外错误:${error}`);
})
);
// 尝试解码无效数据
yield* Effect.logInfo("
尝试解码无效用户数据...");
const invalidUser = { id: "不是数字", name: 123 } as any;
yield* Effect.gen(function* () {
const user = yield* Schema.decode(UserSchema)(invalidUser);
yield* Effect.logInfo(`解码用户:${JSON.stringify(user)}`);
}).pipe(
Effect.catchAll((error) =>
Effect.logInfo(`验证失败:
${JSON.stringify(error, null, 2)}`)
)
);
});
// 运行程序
Effect.runPromise(Effect.provide(program, Database.Default));
解释:
提前定义模式澄清了您的合约,并确保类型安全和运行时验证。
反模式:
首先用隐式 any 类型定义逻辑,然后作为事后考虑添加验证。这导致脆弱的代码,缺乏清晰的合约。
理由:
在编写实现逻辑之前,使用 Effect/Schema 定义数据模型和函数签名的形状。
这种“模式优先”方法将“什么”(数据形状)与“如何”(实现)分开。它为编译时静态类型和运行时验证提供了单一事实来源。
使用 Brand 建模已验证域类型
规则: 使用 Brand 定义像 Email、UserId 或 PositiveInt 这样的类型,确保只能构造和使用有效值。
好例子:
import { Brand } from "effect";
// 为 Email 定义品牌类型
type Email = string & Brand.Brand<"Email">;
// 只接受 Email,而不是任何字符串的函数
function sendWelcome(email: Email) {
// ...
}
// 构造 Email 值(不安全,请参见下一个模式进行验证)
const email = "user@example.com" as Email;
sendWelcome(email); // 正常
// sendWelcome("not-an-email"); // 类型错误!(注释以允许编译)
解释:
Brand.Branded<T, Name>创建一个与其基础类型不同的新类型。- 只有明确标记为
Email的值才能在使用Email的地方使用。 - 这防止了域类型的意外混合。
反模式:
对域特定值(如邮箱、用户 ID 或货币代码)使用纯字符串或数字,这可能导致意外误用和难以捕获的错误。
理由:
使用 Brand 实用程序从 string 或 number 等原语创建域特定类型。
这防止意外误用,并使非法状态在代码库中不可表示。
品牌类型增加了一层类型安全性,确保像 Email、UserId 或 PositiveInt 这样的值不会与纯字符串或数字混淆。
它们帮助您在编译时捕获错误,并使您的代码更自文档化。
使用 Schema.decode 解析和验证数据
规则: 使用 Schema.decode 解析和验证数据。
好例子:
import { Effect, Schema } from "effect";
interface User {
name: string;
}
const UserSchema = Schema.Struct({
name: Schema.String,
}) as Schema.Schema<User>;
const processUserInput = (input: unknown) =>
Effect.gen(function* () {
const user = yield* Schema.decodeUnknown(UserSchema)(input);
return `欢迎,${user.name}!`;
}).pipe(
Effect.catchTag("ParseError", () => Effect.succeed("无效的用户数据。"))
);
// 演示模式解析
const program = Effect.gen(function* () {
// 使用有效输入测试
const validInput = { name: "Paul" };
const validResult = yield* processUserInput(validInput);
yield* Effect.logInfo(`有效输入结果:${validResult}`);
// 使用无效输入测试
const invalidInput = { age: 25 }; // 缺少 'name' 字段
const invalidResult = yield* processUserInput(invalidInput);
yield* Effect.logInfo(`无效输入结果:${invalidResult}`);
// 使用完全无效输入测试
const badInput = "不是对象";
const badResult = yield* processUserInput(badInput);
yield* Effect.logInfo(`错误输入结果:${badResult}`);
});
Effect.runPromise(program);
解释:
Schema.decode 将解析和验证集成到 Effect 工作流中,使错误处理可组合和类型安全。
反模式:
使用 Schema.parse(schema)(input),因为它抛出异常。这强制您使用 try/catch 块,破坏了 Effect 的可组合性。
理由:
当您需要根据 Schema 解析或验证数据时,使用 Schema.decode(schema) 函数。它接受 unknown 输入并返回 Effect。
与抛出异常的旧 Schema.parse 不同,Schema.decode 完全集成到 Effect 生态系统中,允许您使用 Effect.catchTag 等操作符优雅地处理验证失败。
验证和解析品牌类型
规则: 结合 Schema 和 Brand 来验证和解析品牌类型,保证在运行时只能创建有效的域值。
好例子:
import { Brand, Effect, Schema } from "effect";
// 为 Email 定义品牌类型
type Email = string & Brand.Brand<"Email">;
// 为 Email 验证创建 Schema
const EmailSchema = Schema.String.pipe(
Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/), // 简单的邮箱正则表达式
Schema.brand("Email" as const) // 附加品牌
);
// 在运行时解析和验证邮箱
const parseEmail = (input: string) =>
Effect.try({
try: () => Schema.decodeSync(EmailSchema)(input),
catch: (err) => `无效的邮箱:${String(err)}`,
});
// 使用
parseEmail("user@example.com").pipe(
Effect.match({
onSuccess: (email) => console.log("有效邮箱:", email),
onFailure: (err) => console.error(err),
})
);
解释:
Schema用于定义品牌类型的验证逻辑。Brand.schema<Email>()将品牌附加到模式,因此只有验证的值才能构造为Email。- 此模式确保编译时和运行时安全。
反模式:
在没有运行时验证的情况下标记值,或接受未验证的用户输入作为品牌类型,这可能导致无效的域值和运行时错误。
理由:
结合使用 Schema 和 Brand 在运行时验证和解析品牌类型。
这确保只有通过验证逻辑的值才能构造为品牌类型,使您的域模型健壮且类型安全。
虽然在类型级别标记类型可以防止意外误用,但需要运行时验证以确保只有来自用户输入、API 或外部源的有效值才能被构造。
避免长链的 .andThen;改用生成器
规则: 首选生成器而不是长链的 .andThen。
好例子:
import { Effect } from "effect";
// 用日志定义我们的步骤
const step1 = (): Effect.Effect<number> =>
Effect.succeed(42).pipe(Effect.tap((n) => Effect.log(`步骤 1:${n}`)));
const step2 = (a: number): Effect.Effect<string> =>
Effect.succeed(`结果:${a * 2}`).pipe(
Effect.tap((s) => Effect.log(`步骤 2:${s}`))
);
// 使用 Effect.gen 提高可读性
const program = Effect.gen(function* () {
const a = yield* step1();
const b = yield* step2(a);
return b;
});
// 运行程序
const programWithLogging = Effect.gen(function* () {
const result = yield* program;
yield* Effect.log(`最终结果:${result}`);
return result;
});
Effect.runPromise(programWithLogging);
解释:
生成器保持顺序逻辑可读且易于维护。
反模式:
import { Effect } from "effect";
declare const step1: () => Effect.Effect<any>;
declare const step2: (a: any) => Effect.Effect<any>;
step1().pipe(Effect.flatMap((a) => step2(a))); // 或 .andThen
链式多个 .flatMap 或 .andThen 调用导致深度嵌套、难以阅读的代码。
理由:
对于涉及超过两个步骤的顺序逻辑,首选 Effect.gen 而不是链式多个 .andThen 或 .flatMap 调用。
Effect.gen 提供了一个平坦、线性的代码结构,比深度嵌套的函数链更容易阅读和调试。
区分“未找到”和错误
规则: 使用 Effect<Option<A>> 区分可恢复的“未找到”情况和实际失败。
好例子:
查找用户的函数可能会失败,如果数据库关闭,或者可能成功但找不到用户。返回类型 Effect.Effect<Option.Option<User>, DatabaseError> 使此合约完全清晰。
import { Effect, Option, Data } from "effect";
interface User {
id: number;
name: string;
}
class DatabaseError extends Data.TaggedError("DatabaseError") {}
// 此签名非常诚实地描述了其可能的结果。
const findUserInDb = (
id: number
): Effect.Effect<Option.Option<User>, DatabaseError> =>
Effect.gen(function* () {
// 这可能以 DatabaseError 失败
const dbResult = yield* Effect.try({
try: () => (id === 1 ? { id: 1, name: "Paul" } : null),
catch: () => new DatabaseError(),
});
// 我们将可能为 null 的结果包装在 Option 中
return Option.fromNullable(dbResult);
});
// 调用者现在可以显式处理所有三种情况。
const program = (id: number) =>
findUserInDb(id).pipe(
Effect.flatMap((maybeUser) =>
Option.match(maybeUser, {
onNone: () =>
Effect.logInfo(`结果:未找到 ID 为 ${id} 的用户。`),
onSome: (user) => Effect.logInfo(`结果:找到用户 ${user.name}。`),
})
),
Effect.catchAll((error) =>
Effect.logInfo("错误:无法连接到数据库。")
)
);
// 用不同的 ID 运行程序
Effect.runPromise(
Effect.gen(function* () {
// 尝试现有用户
yield* Effect.logInfo("查找 ID 为 1 的用户...");
yield* program(1);
// 尝试不存在的用户
yield* Effect.logInfo("
查找 ID 为 2 的用户...");
yield* program(2);
})
);
反模式:
一个常见的替代方法是创建一个特定的 NotFoundError,并将其放在错误通道中,与其他错误一起。
class NotFoundError extends Data.TaggedError("NotFoundError") {}
// ❌ 此签名混淆了两种不同类型的失败。
const findUserUnsafely = (
id: number
): Effect.Effect<User, DatabaseError | NotFoundError> => {
// ...
return Effect.fail(new NotFoundError());
};
虽然这可行,但可能不太表达性。它将“未找到”结果——这可能是应用程序流程的正常部分——与灾难性的 DatabaseError 相同对待。
使用 Effect<Option<A>> 通常导致更清晰和更精确的业务逻辑。
理由:
当计算可能失败(例如,网络错误)或成功但找不到任何内容时,将其返回类型建模为 Effect<Option<A>>。这将“硬失败”通道与“软失败”(或空)通道分开。
此模式提供了一种精确的方式来处理操作的三种不同结果:
- 成功且有值:
Effect.succeed(Option.some(value)) - 成功但无值:
Effect.succeed(Option.none())(例如,用户未找到) - 失败:
Effect.fail(new DatabaseError())(例如,数据库连接丢失)
通过在 Effect 的成功通道中使用 Option,您保持错误通道清洁,用于真正的、意外的或不可恢复的错误。“未找到”情况通常是业务逻辑的预期和可恢复部分,Option.none() 完美地建模了这一点。
使用 Brand 建模已验证域类型
规则: 使用 Brand 建模已验证域类型。
好例子:
import { Brand, Option } from "effect";
type Email = string & Brand.Brand<"Email">;
const makeEmail = (s: string): Option.Option<Email> =>
s.includes("@") ? Option.some(s as Email) : Option.none();
// 函数现在可以信任其输入是有效的邮箱。
const sendEmail = (email: Email, body: string) => {
/* ... */
};
解释:
品牌确保只有验证的值才能使用,减少错误和重复检查。
反模式:
“原语迷恋”——使用原始类型(string、number)并在每个使用它们的函数中执行验证。这是重复的且容易出错。
理由:
对于具有特定规则的域原语(例如,有效的邮箱),创建品牌类型。这确保只有在通过验证检查后才能创建值。
此模式将验证移动到系统的边界。一旦值被品牌化,应用程序的其余部分可以信任它是有效的,消除了重复检查。
使用 Either 累积多个错误
规则: 使用 Either 累积多个验证错误,而不是在第一个错误上失败。
好例子:
使用带有 allErrors: true 选项的 Schema.decode 完美地演示了此模式。底层机制使用 Either 将所有解析错误收集到数组中,而不是在第一个错误处停止。
import { Effect, Schema, Data, Either } from "effect";
// 定义验证错误类型
class ValidationError extends Data.TaggedError("ValidationError")<{
readonly field: string;
readonly message: string;
}> {}
// 定义用户类型
type User = {
name: string;
email: string;
};
// 用自定义验证定义模式
const UserSchema = Schema.Struct({
name: Schema.String.pipe(
Schema.minLength(3),
Schema.filter((name) => /^[A-Za-z\s]+$/.test(name), {
message: () => "姓名必须只包含字母和空格",
})
),
email: Schema.String.pipe(
Schema.pattern(/@/),
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, {
message: () => "邮箱必须是有效的邮箱地址",
})
),
});
// 示例输入
const invalidInputs: User[] = [
{
name: "Al", // 太短
email: "bob-no-at-sign.com", // 无效模式
},
{
name: "John123", // 包含数字
email: "john@incomplete", // 无效邮箱
},
{
name: "Alice Smith", // 有效
email: "alice@example.com", // 有效
},
];
// 验证单个用户
const validateUser = (input: User) =>
Effect.gen(function* () {
const result = yield* Schema.decode(UserSchema)(input, { errors: "all" });
return result;
});
// 处理多个用户并累积所有错误
const program = Effect.gen(function* () {
yield* Effect.log("验证用户...
");
for (const input of invalidInputs) {
const result = yield* Effect.either(validateUser(input));
yield* Effect.log(`验证用户:${input.name} <${input.email}>`);
// 为清晰起见,分别处理成功和失败情况
// 使用 Either.match,这是处理 Either 值的惯用方式
yield* Either.match(result, {
onLeft: (error) =>
Effect.gen(function* () {
yield* Effect.log("❌ 验证失败:");
yield* Effect.log(error.message);
yield* Effect.log(""); // 空行以提高可读性
}),
onRight: (user) =>
Effect.gen(function* () {
yield* Effect.log(`✅ 用户有效:${JSON.stringify(user)}`);
yield* Effect.log(""); // 空行以提高可读性
}),
});
}
});
// 运行程序
Effect.runSync(program);
反模式:
使用 Effect 的错误通道进行需要多个错误消息的验证。下面的代码只会报告它找到的第一个错误,因为 Effect.fail 会短路整个 Effect.gen 块。
import { Effect } from "effect";
const validateWithEffect = (input: { name: string; email: string }) =>
Effect.gen(function* () {
if (input.name.length < 3) {
// 程序将在此处失败,永远不会检查邮箱。
return yield* Effect.fail("姓名太短。");
}
if (!input.email.includes("@")) {
return yield* Effect.fail("邮箱无效。");
}
return yield* Effect.succeed(input);
});
理由:
当您需要执行多个验证检查并收集所有失败时,使用 Either<E, A> 数据类型。Either 表示可以是两个可能性之一的值:Left<E>(通常用于失败)或 Right<A>(通常用于成功)。
Effect 错误通道设计为短路。一旦 Effect 失败,整个计算停止,错误被传播。这对于处理不可恢复的错误(如丢失数据库连接)是完美的。
然而,对于验证用户输入等任务,这是糟糕的用户体验。您希望一次性向用户展示所有错误。
Either 是解决方案。由于它是纯数据结构,您可以运行多个返回 Either 的检查,然后组合结果以累积所有 Left(错误)值。Effect/Schema 模块内部使用此模式来提供强大的错误累积。