Effect-TS patterns for Domain Modeling. Use when working with domain modeling in Effect-TS applications.
This skill provides 15 curated Effect-TS patterns for domain modeling. Use this skill when working on tasks related to:
Rule: Use Data.TaggedError to create typed, distinguishable errors for your domain.
Good Example:
import { Effect, Data } from "effect"
// ============================================
// 1. Define tagged errors for your domain
// ============================================
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. Use in Effect functions
// ============================================
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: "Missing @ symbol"
}))
}
return Effect.succeed(email)
}
const findUser = (id: string): Effect.Effect<User, UserNotFoundError> => {
// Simulate database lookup
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)
// Simulate duplicate check
if (validEmail === "taken@example.com") {
return yield* Effect.fail(new DuplicateUserError({ email: validEmail }))
}
return {
id: crypto.randomUUID(),
email: validEmail,
name,
}
})
// ============================================
// 3. Handle errors by tag
// ============================================
const program = createUser("alice@example.com", "Alice").pipe(
Effect.catchTag("InvalidEmailError", (error) =>
Effect.succeed({
id: "fallback",
email: "default@example.com",
name: `${error.email} was invalid: ${error.reason}`,
})
),
Effect.catchTag("DuplicateUserError", (error) =>
Effect.fail(new Error(`Email ${error.email} already registered`))
)
)
// ============================================
// 4. Match on all errors
// ============================================
const handleAllErrors = createUser("bad-email", "Bob").pipe(
Effect.catchTags({
InvalidEmailError: (e) => Effect.succeed(`Invalid: ${e.reason}`),
DuplicateUserError: (e) => Effect.succeed(`Duplicate: ${e.email}`),
})
)
// ============================================
// 5. Run and see results
// ============================================
Effect.runPromise(program)
.then((user) => console.log("Created:", user))
.catch((error) => console.error("Failed:", error))
Rationale:
Create domain-specific errors using Data.TaggedError. Each error type gets a unique _tag for pattern matching.
Plain Error or string messages cause problems:
Tagged errors solve this by making errors typed and distinguishable.
Rule: Use Option instead of null/undefined to make missing values explicit and type-safe.
Good Example:
import { Option, Effect } from "effect"
// ============================================
// 1. Creating Options
// ============================================
// Some - a value is present
const hasValue = Option.some(42)
// None - no value
const noValue = Option.none<number>()
// From nullable - null/undefined becomes None
const fromNull = Option.fromNullable(null) // None
const fromValue = Option.fromNullable("hello") // Some("hello")
// ============================================
// 2. Checking and extracting values
// ============================================
const maybeUser = Option.some({ name: "Alice", age: 30 })
// Check if value exists
if (Option.isSome(maybeUser)) {
console.log(`User: ${maybeUser.value.name}`)
}
// Get with default
const name = Option.getOrElse(
Option.map(maybeUser, u => u.name),
() => "Anonymous"
)
// ============================================
// 3. Transforming Options
// ============================================
const maybeNumber = Option.some(5)
// Map - transform the value if present
const doubled = Option.map(maybeNumber, n => n * 2) // Some(10)
// FlatMap - chain operations that return 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. Domain modeling example
// ============================================
interface User {
readonly id: string
readonly name: string
readonly email: Option.Option<string> // Email is optional
readonly phone: Option.Option<string> // Phone is optional
}
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, () => "no email")
const phone = Option.getOrElse(user.phone, () => "no phone")
return `${user.name}: ${email}, ${phone}`
}
// ============================================
// 5. Use in 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(`Found: ${maybeUser.value.name}`)
} else {
yield* Effect.log("User not found")
}
})
Effect.runPromise(program)
Rationale:
Use Option<A> to represent values that might be absent. This makes "might not exist" explicit in your types, forcing you to handle both cases.
null and undefined cause bugs because:
.property on null crashes at runtimeif (x !== null)Option fixes this by making absence explicit and type-checked.
Rule: Start domain modeling by defining clear interfaces for your business entities.
Good Example:
import { Effect } from "effect"
// ============================================
// 1. Define domain entities as interfaces
// ============================================
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. Create domain functions
// ============================================
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. Use in Effect programs
// ============================================
const program = Effect.gen(function* () {
const user = createUser("alice@example.com", "Alice")
yield* Effect.log(`Created user: ${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: $${order.total.toFixed(2)}`)
return order
})
Effect.runPromise(program)
Rationale:
Start by defining TypeScript interfaces that represent your business entities. Use descriptive names that match your domain language.
Good domain modeling:
Rule: Use Option<A> to explicitly model values that may be absent, avoiding null or undefined.
Good Example:
A function that looks for a user in a database is a classic use case. It might find a user, or it might not. Returning an Option<User> makes this contract explicit and safe.
import { Effect, Option } from "effect";
interface User {
id: number;
name: string;
}
const users: User[] = [
{ id: 1, name: "Paul" },
{ id: 2, name: "Alex" },
];
// This function safely returns an Option, not a User or null.
const findUserById = (id: number): Option.Option<User> => {
const user = users.find((u) => u.id === id);
return Option.fromNullable(user); // A useful helper for existing APIs
};
// The caller MUST handle both cases.
const greeting = (id: number): string =>
findUserById(id).pipe(
Option.match({
onNone: () => "User not found.",
onSome: (user) => `Welcome, ${user.name}!`,
})
);
const program = Effect.gen(function* () {
yield* Effect.log(greeting(1)); // "Welcome, Paul!"
yield* Effect.log(greeting(3)); // "User not found."
});
Effect.runPromise(program);
Anti-Pattern:
The anti-pattern is returning a nullable type (e.g., User | null or User | undefined). This relies on the discipline of every single caller to perform a null check. Forgetting even one check can introduce a runtime error.
interface User {
id: number;
name: string;
}
const users: User[] = [{ id: 1, name: "Paul" }];
// ❌ WRONG: This function's return type is less safe.
const findUserUnsafely = (id: number): User | undefined => {
return users.find((u) => u.id === id);
};
const user = findUserUnsafely(3);
// This will throw "TypeError: Cannot read properties of undefined (reading 'name')"
// because the caller forgot to check if the user exists.
console.log(`User's name is ${user.name}`);
Rationale:
Represent values that may be absent with Option<A>. Use Option.some(value) to represent a present value and Option.none() for an absent one. This creates a container that forces you to handle both possibilities.
Functions that can return a value or null/undefined are a primary source of runtime errors in TypeScript (Cannot read properties of null).
The Option type solves this by making the possibility of an absent value explicit in the type system. A function that returns Option<User> cannot be mistaken for a function that returns User. The compiler forces you to handle the None case before you can access the value inside a Some, eliminating an entire class of bugs.
Rule: Use Effect.gen for business logic.
Good Example:
import { Effect } from "effect";
// Concrete implementations for demonstration
const validateUser = (
data: any
): Effect.Effect<{ email: string; password: string }, Error, never> =>
Effect.gen(function* () {
yield* Effect.logInfo(`Validating user data: ${JSON.stringify(data)}`);
if (!data.email || !data.password) {
return yield* Effect.fail(new Error("Email and password are required"));
}
if (data.password.length < 6) {
return yield* Effect.fail(
new Error("Password must be at least 6 characters")
);
}
yield* Effect.logInfo("✅ User data validated successfully");
return { email: data.email, password: data.password };
});
const hashPassword = (pw: string): Effect.Effect<string, never, never> =>
Effect.gen(function* () {
yield* Effect.logInfo("Hashing password...");
// Simulate password hashing
const timestamp = yield* Effect.sync(() => Date.now());
const hashed = `hashed_${pw}_${timestamp}`;
yield* Effect.logInfo("✅ Password hashed successfully");
return hashed;
});
const dbCreateUser = (data: {
email: string;
password: string;
}): Effect.Effect<{ id: number; email: string }, never, never> =>
Effect.gen(function* () {
yield* Effect.logInfo(`Creating user in database: ${data.email}`);
// Simulate database operation
const user = { id: Math.floor(Math.random() * 1000), email: data.email };
yield* Effect.logInfo(`✅ User created with 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 });
});
// Demonstrate using Effect.gen for business logic
const program = Effect.gen(function* () {
yield* Effect.logInfo("=== Using Effect.gen for Business Logic Demo ===");
// Example 1: Successful user creation
yield* Effect.logInfo("\n1. Creating a valid user:");
const validUser = yield* createUser({
email: "paul@example.com",
password: "securepassword123",
}).pipe(
Effect.catchAll((error) =>
Effect.gen(function* () {
yield* Effect.logError(`Failed to create user: ${error.message}`);
return { id: -1, email: "error" };
})
)
);
yield* Effect.logInfo(`Created user: ${JSON.stringify(validUser)}`);
// Example 2: Invalid user data
yield* Effect.logInfo("\n2. Attempting to create user with invalid data:");
const invalidUser = yield* createUser({
email: "invalid@example.com",
password: "123", // Too short
}).pipe(
Effect.catchAll((error) =>
Effect.gen(function* () {
yield* Effect.logError(`Failed to create user: ${error.message}`);
return { id: -1, email: "error" };
})
)
);
yield* Effect.logInfo(`Result: ${JSON.stringify(invalidUser)}`);
yield* Effect.logInfo("\n✅ Business logic demonstration completed!");
});
Effect.runPromise(program);
Explanation:
Effect.gen allows you to express business logic in a clear, sequential style,
improving maintainability.
Anti-Pattern:
Using long chains of .andThen or .flatMap for multi-step business logic.
This is harder to read and pass state between steps.
Rationale:
Use Effect.gen to write your core business logic, especially when it involves
multiple sequential steps or conditional branching.
Generators provide a syntax that closely resembles standard synchronous code
(async/await), making complex workflows significantly easier to read, write,
and debug.
Rule: Use Schema.transform to safely convert data types during the validation and parsing process.
Good Example:
This schema parses a string but produces a Date object, making the final data structure much more useful.
import { Schema, Effect } from "effect";
// Define types for better type safety
type RawEvent = {
name: string;
timestamp: string;
};
type ParsedEvent = {
name: string;
timestamp: Date;
};
// Define the schema for our event
const ApiEventSchema = Schema.Struct({
name: Schema.String,
timestamp: Schema.String,
});
// Example input
const rawInput: RawEvent = {
name: "User Login",
timestamp: "2025-06-22T20:08:42.000Z",
};
// Parse and transform
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 year: ${event.timestamp.getFullYear()}`);
yield* Effect.log(`Full event: ${JSON.stringify(event, null, 2)}`);
return event;
} catch (error) {
yield* Effect.logError(`Failed to parse event: ${error}`);
throw error;
}
}).pipe(
Effect.catchAll((error) =>
Effect.gen(function* () {
yield* Effect.logError(`Program error: ${error}`);
return null;
})
)
);
Effect.runPromise(programWithLogging);
transformOrFail is perfect for creating branded types, as the validation can fail.
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, "Invalid email format")),
(email) => Either.right(email)
)
);
const result = Schema.decode(Email)("paul@example.com"); // Succeeds
const errorResult = Schema.decode(Email)("invalid-email"); // Fails
Anti-Pattern:
Performing validation and transformation in two separate steps. This is more verbose, requires creating intermediate types, and separates the validation logic from the transformation logic.
import { Schema, Effect } from "effect";
// ❌ WRONG: Requires an intermediate "Raw" type.
const RawApiEventSchema = Schema.Struct({
name: Schema.String,
timestamp: Schema.String,
});
const rawInput = { name: "User Login", timestamp: "2025-06-22T20:08:42.000Z" };
// The logic is now split into two distinct, less cohesive steps.
const program = Schema.decode(RawApiEventSchema)(rawInput).pipe(
Effect.map((rawEvent) => ({
...rawEvent,
timestamp: new Date(rawEvent.timestamp), // Manual transformation after parsing.
}))
);
Rationale:
To convert data from one type to another as part of the validation process, use Schema.transform. This allows you to define a schema that parses an input type (e.g., string) and outputs a different, richer domain type (e.g., Date).
Often, the data you receive from external sources (like an API) isn't in the ideal format for your application's domain model. For example, dates are sent as ISO strings, but you want to work with Date objects.
Schema.transform integrates this conversion directly into the parsing step. It takes two functions: one to decode the input type into the domain type, and one to encode it back. This makes your schema the single source of truth for both the shape and the type transformation of your data.
For transformations that can fail (like creating a branded type), you can use Schema.transformOrFail, which allows the decoding step to return an Either.
Rule: Define type-safe errors with Data.TaggedError.
Good Example:
import { Data, Effect } from "effect";
// Define our tagged error type
class DatabaseError extends Data.TaggedError("DatabaseError")<{
readonly cause: unknown;
}> {}
// Function that simulates a database error
const findUser = (
id: number
): Effect.Effect<{ id: number; name: string }, DatabaseError> =>
Effect.gen(function* () {
if (id < 0) {
return yield* Effect.fail(new DatabaseError({ cause: "Invalid ID" }));
}
return { id, name: `User ${id}` };
});
// Create a program that demonstrates error handling
const program = Effect.gen(function* () {
// Try to find a valid user
yield* Effect.logInfo("Looking up user 1...");
yield* Effect.gen(function* () {
const user = yield* findUser(1);
yield* Effect.logInfo(`Found user: ${JSON.stringify(user)}`);
}).pipe(
Effect.catchAll((error) =>
Effect.logInfo(`Error finding user: ${error._tag} - ${error.cause}`)
)
);
// Try to find an invalid user
yield* Effect.logInfo("\nLooking up user -1...");
yield* Effect.gen(function* () {
const user = yield* findUser(-1);
yield* Effect.logInfo(`Found user: ${JSON.stringify(user)}`);
}).pipe(
Effect.catchTag("DatabaseError", (error) =>
Effect.logInfo(`Database error: ${error._tag} - ${error.cause}`)
)
);
});
// Run the program
Effect.runPromise(program);
Explanation:
Tagged errors allow you to handle errors in a type-safe, self-documenting way.
Anti-Pattern:
Using generic Error objects or strings in the error channel. This loses all
type information, forcing consumers to use catchAll and perform unsafe
checks.
Rationale:
For any distinct failure mode in your application, define a custom error class
that extends Data.TaggedError.
This gives each error a unique, literal _tag that Effect can use for type
discrimination with Effect.catchTag, making your error handling fully
type-safe.
Rule: Define contracts upfront with schema.
Good Example:
import { Schema, Effect, Data } from "effect";
// Define User schema and type
const UserSchema = Schema.Struct({
id: Schema.Number,
name: Schema.String,
});
type User = Schema.Schema.Type<typeof UserSchema>;
// Define error type
class UserNotFound extends Data.TaggedError("UserNotFound")<{
readonly id: number;
}> {}
// Create database service implementation
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 })),
}),
}) {}
// Create a program that demonstrates schema and error handling
const program = Effect.gen(function* () {
const db = yield* Database;
// Try to get an existing user
yield* Effect.logInfo("Looking up user 1...");
const user1 = yield* db.getUser(1);
yield* Effect.logInfo(`Found user: ${JSON.stringify(user1)}`);
// Try to get a non-existent user
yield* Effect.logInfo("\nLooking up user 999...");
yield* Effect.logInfo("Attempting to get user 999...");
yield* Effect.gen(function* () {
const user = yield* db.getUser(999);
yield* Effect.logInfo(`Found user: ${JSON.stringify(user)}`);
}).pipe(
Effect.catchAll((error) => {
if (error instanceof UserNotFound) {
return Effect.logInfo(`Error: User with id ${error.id} not found`);
}
return Effect.logInfo(`Unexpected error: ${error}`);
})
);
// Try to decode invalid data
yield* Effect.logInfo("\nTrying to decode invalid user data...");
const invalidUser = { id: "not-a-number", name: 123 } as any;
yield* Effect.gen(function* () {
const use
<!-- Content truncated for initial SEO render. Open the source file tab for the full file. -->
npx skills add PaulJPhilp/effect-patterns-domain-modeling下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer