This skill provides 15 curated Effect-TS patterns for domain modeling.
Use this skill when working on tasks related to:
Tagged errors solve this by making errors typed and distinguishable.
Option fixes this by making absence explicit and type-checked.
Start by defining TypeScript interfaces that represent your business entities. Use descriptive names that match your domain language.
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" },
];
const findUserById = (id: number): Option.Option<User> => {
const user = users.find((u) => u.id === id);
return Option.fromNullable(user);
};
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));
yield* Effect.log(greeting(3));
});
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" }];
const findUserUnsafely = (id: number): User | undefined => {
return users.find((u) => u.id === id);
};
const user = findUserUnsafely(3);
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.
Use Effect.gen for Business Logic
Rule: Use Effect.gen for business logic.
Good Example:
import { Effect } from "effect";
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...");
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}`);
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 });
});
const program = Effect.gen(function* () {
yield* Effect.logInfo("=== Using Effect.gen for Business Logic Demo ===");
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)}`);
yield* Effect.logInfo("\n2. Attempting to create user with invalid data:");
const invalidUser = yield* createUser({
email: "invalid@example.com",
password: "123",
}).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.
Transform Data During Validation with Schema
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";
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: "User Login",
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 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");
const errorResult = Schema.decode(Email)("invalid-email");
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";
const RawApiEventSchema = Schema.Struct({
name: Schema.String,
timestamp: Schema.String,
});
const rawInput = { name: "User Login", timestamp: "2025-06-22T20:08:42.000Z" };
const program = Schema.decode(RawApiEventSchema)(rawInput).pipe(
Effect.map((rawEvent) => ({
...rawEvent,
timestamp: new Date(rawEvent.timestamp),
}))
);
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.
Define Type-Safe Errors with Data.TaggedError
Rule: Define type-safe errors with Data.TaggedError.
Good Example:
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: "Invalid ID" }));
}
return { id, name: `User ${id}` };
});
const program = Effect.gen(function* () {
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}`)
)
);
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}`)
)
);
});
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.
Define Contracts Upfront with Schema
Rule: Define contracts upfront with schema.
Good Example:
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("Looking up user 1...");
const user1 = yield* db.getUser(1);
yield* Effect.logInfo(`Found user: ${JSON.stringify(user1)}`);
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}`);
})
);
yield* Effect.logInfo("\nTrying to decode invalid user data...");
const invalidUser = { id: "not-a-number", name: 123 } as any;
yield* Effect.gen(function* () {
const user = yield* Schema.decode(UserSchema)(invalidUser);
yield* Effect.logInfo(`Decoded user: ${JSON.stringify(user)}`);
}).pipe(
Effect.catchAll((error) =>
Effect.logInfo(`Validation failed:\n${JSON.stringify(error, null, 2)}`)
)
);
});
Effect.runPromise(Effect.provide(program, Database.Default));
Explanation:
Defining schemas upfront clarifies your contracts and ensures both type safety
and runtime validation.
Anti-Pattern:
Defining logic with implicit any types first and adding validation later as
an afterthought. This leads to brittle code that lacks a clear contract.
Rationale:
Before writing implementation logic, define the shape of your data models and
function signatures using Effect/Schema.
This "schema-first" approach separates the "what" (the data shape) from the
"how" (the implementation). It provides a single source of truth for both
compile-time static types and runtime validation.
Modeling Validated Domain Types with Brand
Rule: Use Brand to define types like Email, UserId, or PositiveInt, ensuring only valid values can be constructed and used.
Good Example:
import { Brand } from "effect";
type Email = string & Brand.Brand<"Email">;
function sendWelcome(email: Email) {
}
const email = "user@example.com" as Email;
sendWelcome(email);
Explanation:
Brand.Branded<T, Name> creates a new type that is distinct from its base type.
- Only values explicitly branded as
Email can be used where an Email is required.
- This prevents accidental mixing of domain types.
Anti-Pattern:
Using plain strings or numbers for domain-specific values (like emails, user IDs, or currency codes), which can lead to accidental misuse and bugs that are hard to catch.
Rationale:
Use the Brand utility to create domain-specific types from primitives like string or number.
This prevents accidental misuse and makes illegal states unrepresentable in your codebase.
Branded types add a layer of type safety, ensuring that values like Email, UserId, or PositiveInt are not confused with plain strings or numbers.
They help you catch bugs at compile time and make your code more self-documenting.
Parse and Validate Data with Schema.decode
Rule: Parse and validate data with Schema.decode.
Good Example:
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 `Welcome, ${user.name}!`;
}).pipe(
Effect.catchTag("ParseError", () => Effect.succeed("Invalid user data."))
);
const program = Effect.gen(function* () {
const validInput = { name: "Paul" };
const validResult = yield* processUserInput(validInput);
yield* Effect.logInfo(`Valid input result: ${validResult}`);
const invalidInput = { age: 25 };
const invalidResult = yield* processUserInput(invalidInput);
yield* Effect.logInfo(`Invalid input result: ${invalidResult}`);
const badInput = "not an object";
const badResult = yield* processUserInput(badInput);
yield* Effect.logInfo(`Bad input result: ${badResult}`);
});
Effect.runPromise(program);
Explanation:
Schema.decode integrates parsing and validation into the Effect workflow,
making error handling composable and type-safe.
Anti-Pattern:
Using Schema.parse(schema)(input), as it throws an exception. This forces
you to use try/catch blocks, which breaks the composability of Effect.
Rationale:
When you need to parse or validate data against a Schema, use the
Schema.decode(schema) function. It takes an unknown input and returns an
Effect.
Unlike the older Schema.parse which throws, Schema.decode is fully
integrated into the Effect ecosystem, allowing you to handle validation
failures gracefully with operators like Effect.catchTag.
Validating and Parsing Branded Types
Rule: Combine Schema and Brand to validate and parse branded types, guaranteeing only valid domain values are created at runtime.
Good Example:
import { Brand, Effect, Schema } from "effect";
type Email = string & Brand.Brand<"Email">;
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) => `Invalid email: ${String(err)}`,
});
parseEmail("user@example.com").pipe(
Effect.match({
onSuccess: (email) => console.log("Valid email:", email),
onFailure: (err) => console.error(err),
})
);
Explanation:
Schema is used to define validation logic for the branded type.
Brand.schema<Email>() attaches the brand to the schema, so only validated values can be constructed as Email.
- This pattern ensures both compile-time and runtime safety.
Anti-Pattern:
Branding values without runtime validation, or accepting unvalidated user input as branded types, which can lead to invalid domain values and runtime bugs.
Rationale:
Use Schema in combination with Brand to validate and parse branded types at runtime.
This ensures that only values passing your validation logic can be constructed as branded types, making your domain models robust and type-safe.
While branding types at the type level prevents accidental misuse, runtime validation is needed to ensure only valid values are constructed from user input, APIs, or external sources.
Avoid Long Chains of .andThen; Use Generators Instead
Rule: Prefer generators over long chains of .andThen.
Good Example:
import { Effect } from "effect";
const step1 = (): Effect.Effect<number> =>
Effect.succeed(42).pipe(Effect.tap((n) => Effect.log(`Step 1: ${n}`)));
const step2 = (a: number): Effect.Effect<string> =>
Effect.succeed(`Result: ${a * 2}`).pipe(
Effect.tap((s) => Effect.log(`Step 2: ${s}`))
);
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(`Final result: ${result}`);
return result;
});
Effect.runPromise(programWithLogging);
Explanation:
Generators keep sequential logic readable and easy to maintain.
Anti-Pattern:
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)));
Chaining many .flatMap or .andThen calls leads to deeply nested,
hard-to-read code.
Rationale:
For sequential logic involving more than two steps, prefer Effect.gen over
chaining multiple .andThen or .flatMap calls.
Effect.gen provides a flat, linear code structure that is easier to read and
debug than deeply nested functional chains.
Distinguish 'Not Found' from Errors
Good Example:
This function to find a user can fail if the database is down, or it can succeed but find no user. The return type Effect.Effect<Option.Option<User>, DatabaseError> makes this contract perfectly clear.
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* () {
const dbResult = yield* Effect.try({
try: () => (id === 1 ? { id: 1, name: "Paul" } : null),
catch: () => new DatabaseError(),
});
return Option.fromNullable(dbResult);
});
const program = (id: number) =>
findUserInDb(id).pipe(
Effect.flatMap((maybeUser) =>
Option.match(maybeUser, {
onNone: () =>
Effect.logInfo(`Result: User with ID ${id} was not found.`),
onSome: (user) => Effect.logInfo(`Result: Found user ${user.name}.`),
})
),
Effect.catchAll((error) =>
Effect.logInfo("Error: Could not connect to the database.")
)
);
Effect.runPromise(
Effect.gen(function* () {
yield* Effect.logInfo("Looking for user with ID 1...");
yield* program(1);
yield* Effect.logInfo("\nLooking for user with ID 2...");
yield* program(2);
})
);
Anti-Pattern:
A common alternative is to create a specific NotFoundError and put it in the error channel alongside other errors.
class NotFoundError extends Data.TaggedError("NotFoundError") {}
const findUserUnsafely = (
id: number
): Effect.Effect<User, DatabaseError | NotFoundError> => {
return Effect.fail(new NotFoundError());
};
While this works, it can be less expressive. It treats a "not found" result—which might be a normal part of your application's flow—the same as a catastrophic DatabaseError.
Using Effect<Option<A>> often leads to clearer and more precise business logic.
Rationale:
When a computation can fail (e.g., a network error) or succeed but find nothing, model its return type as Effect<Option<A>>. This separates the "hard failure" channel from the "soft failure" (or empty) channel.
This pattern provides a precise way to handle three distinct outcomes of an operation:
- Success with a value:
Effect.succeed(Option.some(value))
- Success with no value:
Effect.succeed(Option.none()) (e.g., user not found)
- Failure:
Effect.fail(new DatabaseError()) (e.g., database connection lost)
By using Option inside the success channel of an Effect, you keep the error channel clean for true, unexpected, or unrecoverable errors. The "not found" case is often an expected and recoverable part of your business logic, and Option.none() models this perfectly.
Model Validated Domain Types with Brand
Rule: Model validated domain types with Brand.
Good Example:
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) => {
};
Explanation:
Branding ensures that only validated values are used, reducing bugs and
repetitive checks.
Anti-Pattern:
"Primitive obsession"—using raw primitives (string, number) and performing
validation inside every function that uses them. This is repetitive and
error-prone.
Rationale:
For domain primitives that have specific rules (e.g., a valid email), create a
Branded Type. This ensures a value can only be created after passing a
validation check.
This pattern moves validation to the boundaries of your system. Once a value
has been branded, the rest of your application can trust that it is valid,
eliminating repetitive checks.
Accumulate Multiple Errors with Either
Rule: Use Either to accumulate multiple validation errors instead of failing on the first one.
Good Example:
Using Schema.decode with the allErrors: true option demonstrates this pattern perfectly. The underlying mechanism uses Either to collect all parsing errors into an array instead of stopping at the first one.
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: () => "name must contain only letters and spaces",
})
),
email: Schema.String.pipe(
Schema.pattern(/@/),
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, {
message: () => "email must be a valid email address",
})
),
});
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("Validating users...\n");
for (const input of invalidInputs) {
const result = yield* Effect.either(validateUser(input));
yield* Effect.log(`Validating user: ${input.name} <${input.email}>`);
yield* Either.match(result, {
onLeft: (error) =>
Effect.gen(function* () {
yield* Effect.log("❌ Validation failed:");
yield* Effect.log(error.message);
yield* Effect.log("");
}),
onRight: (user) =>
Effect.gen(function* () {
yield* Effect.log(`✅ User is valid: ${JSON.stringify(user)}`);
yield* Effect.log("");
}),
});
}
});
Effect.runSync(program);
Anti-Pattern:
Using Effect's error channel for validation that requires multiple error messages. The code below will only ever report the first error it finds, because Effect.fail short-circuits the entire Effect.gen block.
import { Effect } from "effect";
const validateWithEffect = (input: { name: string; email: string }) =>
Effect.gen(function* () {
if (input.name.length < 3) {
return yield* Effect.fail("Name is too short.");
}
if (!input.email.includes("@")) {
return yield* Effect.fail("Email is invalid.");
}
return yield* Effect.succeed(input);
});
Rationale:
When you need to perform multiple validation checks and collect all failures, use the Either<E, A> data type. Either represents a value that can be one of two possibilities: a Left<E> (typically for failure) or a Right<A> (typically for success).
The Effect error channel is designed to short-circuit. The moment an Effect fails, the entire computation stops and the error is propagated. This is perfect for handling unrecoverable errors like a lost database connection.
However, for tasks like validating a user's input, this is poor user experience. You want to show the user all of their mistakes at once.
Either is the solution. Since it's a pure data structure, you can run multiple checks that each return an Either, and then combine the results to accumulate all the Left (error) values. The Effect/Schema module uses this pattern internally to provide powerful error accumulation.