code-style
Code Style in Effect
Overview
Effect's idiomatic style centers on three core principles:
- Functional Programming Only - No imperative logic (loops, mutation, conditionals)
- Schema-First Data Modeling - Define ALL data structures as Effect Schemas
- Match-First Control Flow - Define ALL conditional logic using Effect Match
Additional patterns include:
- Branded types - Nominal typing for primitives (built into Schema)
- Dual APIs - Both data-first and data-last
- Generator syntax - Effect.gen for readability
- Project organization - Layers, services, domains
Core Principles
0. No Imperative Logic - Functional Programming Only
NEVER use imperative constructs. All code must follow functional programming principles:
- No complex conditionals:
else ifchains, nestedifstatements, ternary operators - Simple
if/elseis allowed: A singleifwith optionalelse(no nesting, noelse if) switch/caseas last resort: PreferMatch.type/Match.value, butswitchis acceptable when Match doesn't fit- No loops:
for,while,do...while,for...of,for...in - No mutation: Reassignment, push/pop/splice, property mutation
Use instead:
- Pattern matching (
Match,Option.match,Either.match,Array.match) - Effect's
Arraymodule (Array.map,Array.filter,Array.reduce,Array.flatMap,Array.filterMap, etc.) - Effect's
Recordmodule (Record.map,Record.filter,Record.get,Record.keys,Record.values, etc.) - Effect's
Structmodule (Struct.pick,Struct.omit,Struct.evolve,Struct.get, etc.) - Effect's
Tuplemodule (Tuple.make,Tuple.getFirst,Tuple.mapBoth, etc.) - Effect's
Predicatemodule (Predicate.and,Predicate.or,Predicate.not,Predicate.struct, etc.) - Effect combinators (
Effect.forEach,Effect.all,Effect.reduce) - Effect's
Functionmodule (pipe,flow,identity,constant,compose) - Recursion for complex iteration
- First-class functions (pass functions as arguments, return functions)
Conditionals - Use Pattern Matching
- Simple
if/else→ Allowed (no nesting, noelse if) else ifchains →Match.value+Match.when(FORBIDDEN aselse if)- Nested
ifstatements → Flatten with early return, orMatch.value+Match.when switch/casestatements → PreferMatch.type+Match.tag, butswitchis acceptable- Ternary operators (
? :) →Match.value+Match.whenor simpleif/else - Single optional value →
Option.match - Chained optional operations →
Option.flatMap+Option.getOrElse - Result/error conditionals →
Either.matchorEffect.match
// ✅ ALLOWED: Simple if/else (not nested, no else if)
if (user.isAdmin) {
return grantFullAccess()
}
return grantLimitedAccess()
// ✅ ALLOWED: Simple if with else
if (isValid) {
process(data)
} else {
handleError()
}
// ❌ FORBIDDEN: else if (use Match instead)
if (user.role === "admin") {
return "full access"
} else if (user.role === "user") {
return "limited access"
} else {
return "no access"
}
// ❌ FORBIDDEN: Nested if
if (user.isActive) {
if (user.isAdmin) {
return "active admin"
}
}
// ❌ FORBIDDEN: ternary
const message = isError ? "Failed" : "Success"
// ❌ FORBIDDEN: direct ._tag access
if (event._tag === "UserCreated") { ... }
const isCreated = event._tag === "UserCreated"
// ❌ FORBIDDEN: ._tag in type definitions
type ConflictTag = Conflict["_tag"] // Never extract _tag as a type
// ❌ FORBIDDEN: ._tag in array predicates
const hasConflict = conflicts.some((c) => c._tag === "MergeConflict")
const mergeConflicts = conflicts.filter((c) => c._tag === "MergeConflict")
const countMerge = conflicts.filter((c) => c._tag === "MergeConflict").length
// ✅ REQUIRED: Schema.is() as predicate
const hasConflict = conflicts.some(Schema.is(MergeConflict))
const mergeConflicts = conflicts.filter(Schema.is(MergeConflict))
const countMerge = conflicts.filter(Schema.is(MergeConflict)).length
// ✅ REQUIRED: Match.value for else-if replacement
const getAccess = (user: User) =>
Match.value(user.role).pipe(
Match.when("admin", () => "full access"),
Match.when("user", () => "limited access"),
Match.orElse(() => "no access")
)
// ✅ REQUIRED: Match.type for multi-case
const getStatusMessage = Match.type<Status>().pipe(
Match.when("pending", () => "waiting"),
Match.when("active", () => "running"),
Match.exhaustive
)
// ✅ ALLOWED: switch as last resort (prefer Match)
switch (status) {
case "pending": return "waiting"
case "active": return "running"
default: return "unknown"
}
// ✅ REQUIRED: Option.match for single optional
const displayName = Option.match(maybeUser, {
onNone: () => "Guest",
onSome: (user) => user.name
})
// ❌ FORBIDDEN: Nested Option.match (pyramid of doom)
// Signal: every onNone returns the same default
const name = pipe(
findUser(id),
Option.match({
onNone: () => "Unknown",
onSome: (user) =>
Option.match(user.profile, {
onNone: () => "Unknown",
onSome: (profile) => profile.displayName,
}),
}),
)
// ✅ REQUIRED: Option.flatMap chain for multiple optionals
const name = pipe(
findUser(id),
Option.flatMap((user) => user.profile),
Option.map((profile) => profile.displayName),
Option.getOrElse(() => "Unknown"),
)
// ✅ REQUIRED: Either.match for results
const result = Either.match(parseResult, {
onLeft: (error) => `Error: ${error}`,
onRight: (value) => `Success: ${value}`
})
// ✅ REQUIRED: Match.tag for discriminated unions (not ._tag access)
const handleEvent = Match.type<AppEvent>().pipe(
Match.tag("UserCreated", (e) => notifyAdmin(e.userId)),
Match.tag("UserDeleted", (e) => cleanupData(e.userId)),
Match.exhaustive
)
// ✅ REQUIRED: Schema.is() for type guards on Schema types (Schema.TaggedClass)
if (Schema.is(UserCreated)(event)) {
// event is narrowed to UserCreated
}
// Schema.TaggedError works with Schema.is(), Effect.catchTag, and Match.tag.
// Always use Schema.TaggedError for domain errors.
When you encounter else if chains, nested if statements, or ternary operators in existing code, refactor them immediately. Simple if/else is acceptable.
Loops - Use Effect's Array Module and Recursion
NEVER use for, while, do...while, for...of, or for...in loops. Use Effect's functional alternatives.
Why Effect's Array module over native Array methods:
Array.findFirstreturnsOption<A>instead ofA | undefinedArray.getreturnsOption<A>for safe indexingArray.filterMapcombines filter and map in one passArray.partitionreturns typed tuple[excluded, satisfying]Array.groupByreturnsRecord<K, NonEmptyArray<A>>Array.matchprovides exhaustive empty/non-empty handling- All functions work with
pipefor composable pipelines - Consistent dual API (data-first and data-last)
import { Array, pipe } from "effect";
// ❌ FORBIDDEN: for loop
const doubled = [];
for (let i = 0; i < numbers.length; i++) {
doubled.push(numbers[i] * 2);
}
// ❌ FORBIDDEN: for...of loop
const results = [];
for (const item of items) {
results.push(process(item));
}
// ❌ FORBIDDEN: while loop
let sum = 0;
let i = 0;
while (i < numbers.length) {
sum += numbers[i];
i++;
}
// ❌ FORBIDDEN: forEach with mutation
const output = [];
items.forEach((item) => output.push(transform(item)));
// ✅ REQUIRED: Array.map for transformation
const doubled = Array.map(numbers, (n) => n * 2);
// or with pipe
const doubled = pipe(
numbers,
Array.map((n) => n * 2),
);
// ✅ REQUIRED: Array.filter for selection
const adults = Array.filter(users, (u) => u.age >= 18);
// ✅ REQUIRED: Array.reduce for accumulation
const sum = Array.reduce(numbers, 0, (acc, n) => acc + n);
// ✅ REQUIRED: Array.flatMap for one-to-many
const allTags = Array.flatMap(posts, (post) => post.tags);
// ✅ REQUIRED: Array.findFirst for search (returns Option)
const admin = Array.findFirst(users, (u) => u.role === "admin");
// ✅ REQUIRED: Array.some/every for predicates
const hasAdmin = Array.some(users, (u) => u.role === "admin");
const allVerified = Array.every(users, (u) => u.verified);
// ✅ REQUIRED: Array.filterMap for filter + transform in one pass
const validEmails = Array.filterMap(users, (u) => (isValidEmail(u.email) ? Option.some(u.email) : Option.none()));
// ✅ REQUIRED: Array.partition to split by predicate
const [minors, adults] = Array.partition(users, (u) => u.age >= 18);
// ✅ REQUIRED: Array.groupBy for grouping
const usersByRole = Array.groupBy(users, (u) => u.role);
// ✅ REQUIRED: Array.dedupe for removing duplicates
const uniqueIds = Array.dedupe(ids);
// ✅ REQUIRED: Array.match for empty vs non-empty handling
const message = Array.match(items, {
onEmpty: () => "No items",
onNonEmpty: (items) => `${items.length} items`,
});
Record operations - use Effect's Record module:
import { Record, pipe } from "effect";
// ✅ REQUIRED: Record.map for transforming values
const doubled = Record.map(prices, (price) => price * 2);
// ✅ REQUIRED: Record.filter for filtering entries
const expensive = Record.filter(prices, (price) => price > 100);
// ✅ REQUIRED: Record.get for safe access (returns Option)
const price = Record.get(prices, "item1");
// ✅ REQUIRED: Record.keys and Record.values
const allKeys = Record.keys(config);
const allValues = Record.values(config);
// ✅ REQUIRED: Record.fromEntries and Record.toEntries
const record = Record.fromEntries([
["a", 1],
["b", 2],
]);
const entries = Record.toEntries(record);
// ✅ REQUIRED: Record.filterMap for filter + transform
const validPrices = Record.filterMap(rawPrices, (value) =>
typeof value === "number" ? Option.some(value) : Option.none(),
);
Struct operations - use Effect's Struct module:
import { Struct, pipe } from "effect";
// ✅ REQUIRED: Struct.pick for selecting properties
const namePart = Struct.pick(user, "firstName", "lastName");
// ✅ REQUIRED: Struct.omit for excluding properties
const publicUser = Struct.omit(user, "password", "ssn");
// ✅ REQUIRED: Struct.evolve for transforming specific fields
const updated = Struct.evolve(user, {
age: (age) => age + 1,
name: (name) => name.toUpperCase(),
});
// ✅ REQUIRED: Struct.get for property access
const getName = Struct.get("name");
const name = getName(user);
Tuple operations - use Effect's Tuple module:
import { Tuple } from "effect";
// ✅ REQUIRED: Tuple.make for creating tuples
const pair = Tuple.make("key", 42);
// ✅ REQUIRED: Tuple.getFirst/getSecond for access
const key = Tuple.getFirst(pair);
const value = Tuple.getSecond(pair);
// ✅ REQUIRED: Tuple.mapFirst/mapSecond/mapBoth for transformation
const upperKey = Tuple.mapFirst(pair, (s) => s.toUpperCase());
const doubled = Tuple.mapSecond(pair, (n) => n * 2);
const both = Tuple.mapBoth(pair, {
onFirst: (s) => s.toUpperCase(),
onSecond: (n) => n * 2,
});
// ✅ REQUIRED: Tuple.at for indexed access
const first = Tuple.at(tuple, 0);
Predicate operations - use Effect's Predicate module:
import { Predicate } from "effect";
// ✅ REQUIRED: Predicate.and/or/not for combining predicates
const isPositive = (n: number) => n > 0;
const isEven = (n: number) => n % 2 === 0;
const isPositiveAndEven = Predicate.and(isPositive, isEven);
const isPositiveOrEven = Predicate.or(isPositive, isEven);
const isNegative = Predicate.not(isPositive);
// ✅ REQUIRED: Predicate.struct for validating object shapes
const isValidUser = Predicate.struct({
name: Predicate.isString,
age: Predicate.isNumber,
});
// ✅ REQUIRED: Predicate.tuple for validating tuple shapes
const isStringNumberPair = Predicate.tuple(Predicate.isString, Predicate.isNumber);
// ✅ REQUIRED: Built-in type guards
Predicate.isString(value);
Predicate.isNumber(value);
Predicate.isNullable(value);
Predicate.isNotNullable(value);
Predicate.isRecord(value);
Effect loops - use Effect combinators:
// ❌ FORBIDDEN: for...of with yield*
const processAll = Effect.gen(function* () {
const results = [];
for (const item of items) {
const result = yield* processItem(item);
results.push(result);
}
return results;
});
// ✅ REQUIRED: Effect.forEach for sequential
const processAll = Effect.forEach(items, processItem);
// ✅ REQUIRED: Effect.all for parallel (when items are Effects)
const results = Effect.all(effects);
// ✅ REQUIRED: Effect.all with concurrency
const results = Effect.all(effects, { concurrency: 10 });
// ✅ REQUIRED: Effect.reduce for accumulation
const total = Effect.reduce(items, 0, (acc, item) => getPrice(item).pipe(Effect.map((price) => acc + price)));
// ✅ REQUIRED: Stream for large/infinite sequences
const processed = Stream.fromIterable(items).pipe(Stream.mapEffect(processItem), Stream.runCollect);
Recursion for complex iteration:
// ❌ FORBIDDEN: while loop for tree traversal
const collectLeaves = (node) => {
const leaves = [];
const stack = [node];
while (stack.length > 0) {
const current = stack.pop();
if (current.children.length === 0) {
leaves.push(current);
} else {
stack.push(...current.children);
}
}
return leaves;
};
// ✅ REQUIRED: Recursion for tree traversal
const collectLeaves = (node: TreeNode): ReadonlyArray<TreeNode> =>
Array.match(node.children, {
onEmpty: () => [node],
onNonEmpty: (children) => Array.flatMap(children, collectLeaves),
});
// ✅ REQUIRED: Recursion with Effect
const processTree = (node: TreeNode): Effect.Effect<Result> =>
node.children.length === 0
? processLeaf(node)
: Effect.forEach(node.children, processTree).pipe(Effect.flatMap(combineResults));
First-class functions - use Effect's Function module:
import { Array, Function, pipe, flow } from "effect";
// ❌ BAD: Inline logic repeated
const processUsers = (users: Array<User>) => users.filter((u) => u.active).map((u) => u.email);
const processOrders = (orders: Array<Order>) => orders.filter((o) => o.active).map((o) => o.total);
// ✅ GOOD: Extract reusable predicates and transformers
const isActive = <T extends { active: boolean }>(item: T) => item.active;
const getEmail = (user: User) => user.email;
const getTotal = (order: Order) => order.total;
// ✅ GOOD: Use pipe for data transformation pipelines
const processUsers = (users: Array<User>) => pipe(users, Array.filter(isActive), Array.map(getEmail));
const processOrders = (orders: Array<Order>) => pipe(orders, Array.filter(isActive), Array.map(getTotal));
// ✅ GOOD: Use flow to compose reusable pipelines
const getActiveEmails = flow(Array.filter(isActive<User>), Array.map(getEmail));
const getActiveTotals = flow(Array.filter(isActive<Order>), Array.map(getTotal));
// ✅ GOOD: Use Function.compose for simple composition
const parseAndValidate = Function.compose(parse, validate);
// ✅ GOOD: Use Function.identity for pass-through
const transform = shouldTransform ? myTransform : Function.identity;
// ✅ GOOD: Use Function.constant for fixed values
const getDefaultUser = Function.constant(defaultUser);
When you encounter imperative loops in existing code, refactor them immediately. This is not optional - imperative logic is a code smell that must be eliminated.
1. Schema-First Data Modeling
Define ALL data structures as Effect Schemas. This is the foundation of type-safe Effect code.
Key principles:
- Use Schema.Class over Schema.Struct - Get methods and Schema.is() type guards
- Use tagged unions over optional properties - Make states explicit
- Use Schema.is() in Match patterns - Combine validation with matching
import { Schema, Match } from "effect";
// ✅ GOOD: Class-based schema with methods
class User extends Schema.Class<User>("User")({
id: Schema.String.pipe(Schema.brand("UserId")),
email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/)),
name: Schema.String.pipe(Schema.nonEmptyString()),
createdAt: Schema.Date,
}) {
get emailDomain() {
return this.email.split("@")[1];
}
}
// ✅ GOOD: Tagged union over optional properties
class Pending extends Schema.TaggedClass<Pending>()("Pending", {
orderId: Schema.String,
items: Schema.Array(Schema.String),
}) {}
class Shipped extends Schema.TaggedClass<Shipped>()("Shipped", {
orderId: Schema.String,
items: Schema.Array(Schema.String),
trackingNumber: Schema.String,
shippedAt: Schema.Date,
}) {}
class Delivered extends Schema.TaggedClass<Delivered>()("Delivered", {
orderId: Schema.String,
items: Schema.Array(Schema.String),
deliveredAt: Schema.Date,
}) {}
const Order = Schema.Union(Pending, Shipped, Delivered);
type Order = Schema.Schema.Type<typeof Order>;
// ✅ GOOD: Schema.is() in Match patterns
const getOrderStatus = (order: Order) =>
Match.value(order).pipe(
Match.when(Schema.is(Pending), () => "Awaiting shipment"),
Match.when(Schema.is(Shipped), (o) => `Tracking: ${o.trackingNumber}`),
Match.when(Schema.is(Delivered), (o) => `Delivered ${o.deliveredAt}`),
Match.exhaustive,
);
// ❌ BAD: Optional properties hide state complexity
const Order = Schema.Struct({
orderId: Schema.String,
items: Schema.Array(Schema.String),
trackingNumber: Schema.optional(Schema.String), // When is this set?
shippedAt: Schema.optional(Schema.Date), // Unclear state
deliveredAt: Schema.optional(Schema.Date), // Can be shipped AND delivered?
});
Why Schema for everything:
- Runtime validation at system boundaries
- Automatic type inference (no duplicate type definitions)
- Encode/decode for serialization
- JSON Schema generation for API docs
- Branded types built-in
- Composable transformations
2. Match-First Control Flow
Define ALL conditional logic and algorithms using Effect Match. Replace if/else chains, switch statements, and ternaries with exhaustive pattern matching.
import { Match } from "effect";
// Process by discriminated union - use Match
const handleEvent = Match.type<AppEvent>().pipe(
Match.tag("UserCreated", (event) => notifyAdmin(event.userId)),
Match.tag("UserDeleted", (event) => cleanupData(event.userId)),
Match.tag("OrderPlaced", (event) => processOrder(event.orderId)),
Match.exhaustive,
);
// Transform values - use Match
const toHttpStatus = Match.type<AppError>().pipe(
Match.tag("NotFound", () => 404),
Match.tag("Unauthorized", () => 401),
Match.tag("ValidationError", () => 400),
Match.tag("InternalError", () => 500),
Match.exhaustive,
);
// Handle options - use Option.match
const displayUser = (maybeUser: Option<User>) =>
Option.match(maybeUser, {
onNone: () => "Guest user",
onSome: (user) => `Welcome, ${user.name}`,
});
// Multi-condition logic - use Match.when
const calculateDiscount = (order: Order) =>
Match.value(order).pipe(
Match.when({ total: (t) => t > 1000, isPremium: true }, () => 0.25),
Match.when({ total: (t) => t > 1000 }, () => 0.15),
Match.when({ isPremium: true }, () => 0.1),
Match.when({ itemCount: (c) => c > 10 }, () => 0.05),
Match.orElse(() => 0),
);
Why Match for everything:
- Exhaustive checking catches missing cases at compile time
- Self-documenting code structure
- No forgotten else branches
- Easy to extend with new cases
- Works perfectly with Schema discriminated unions
3. Property-Based Testing with Schema Arbitrary
Every Schema generates test data via Arbitrary.make(). This is the foundation of Effect testing — never hand-craft test objects.
CRITICAL: See the Testing Skill for comprehensive testing guidance. This section covers Schema-driven Arbitrary patterns; the Testing skill covers
@effect/vitest,it.effect,it.prop, test layers, and the full service-oriented testing pattern.
Key principles:
- Schema constraints generate constrained Arbitraries — Filters are built into Schema, not fast-check
- NEVER use fast-check
.filter()— Use properly-constrained Schemas instead - Use
it.propfrom@effect/vitest— Integrates Schema Arbitrary directly - Test round-trips for every Schema — Encode/decode must be lossless
Why Schema Constraints Over Fast-Check Filters
Fast-check filters (.filter()) discard generated values that don't match the predicate. This is:
- Inefficient — Generates and throws away invalid values
- Fragile — Can fail to find valid values if filter is too restrictive
- Duplicative — Your Schema already defines the constraints
The correct approach: define constraints in your Schema, then Arbitrary.make() generates valid values directly.
import { Schema, Arbitrary } from "effect";
import * as fc from "fast-check";
// ❌ FORBIDDEN: Using fast-check filter
const badArbitrary = fc.integer().filter((n) => n >= 18 && n <= 100);
// Problem: Generates integers, throws away 99% of them
// ❌ FORBIDDEN: Filter on Schema Arbitrary
const UserArbitrary = Arbitrary.make(User);
const badFiltered = UserArbitrary(fc).filter((u) => u.age >= 18);
// Problem: Duplicates constraint logic, wasteful generation
// ✅ REQUIRED: Constraints in Schema definition
const Age = Schema.Number.pipe(
Schema.int(),
Schema.between(18, 100), // Constraint built into Schema
);
class User extends Schema.Class<User>("User")({
id: Schema.String.pipe(Schema.minLength(1)),
name: Schema.String.pipe(Schema.nonEmptyString()),
age: Age, // Uses constrained Age schema
}) {}
// ✅ REQUIRED: Arbitrary generates ONLY valid values
const UserArbitrary = Arbitrary.make(User);
fc.sample(UserArbitrary(fc), 5);
// All 5 users have age between 18-100, guaranteed
Common Schema Constraints for Arbitrary Generation
Use these Schema combinators to constrain generation (never filter):
import { Schema } from "effect";
// Numeric constraints
Schema.Number.pipe(Schema.int()); // Integers only
Schema.Number.pipe(Schema.positive()); // > 0
Schema.Number.pipe(Schema.nonNegative()); // >= 0
Schema.Number.pipe(Schema.between(1, 100)); // 1 <= n <= 100
Schema.Number.pipe(Schema.greaterThan(0)); // > 0
Schema.Number.pipe(Schema.lessThanOrEqualTo(100)); // <= 100
// String constraints
Schema.String.pipe(Schema.minLength(1)); // Non-empty
Schema.String.pipe(Schema.maxLength(100)); // Max 100 chars
Schema.String.pipe(Schema.length(10)); // Exactly 10 chars
Schema.String.pipe(Schema.nonEmptyString()); // Non-empty (alias)
Schema.String.pipe(Schema.pattern(/^[A-Z]{3}$/)); // Matches regex
// Array constraints
Schema.Array(Item).pipe(Schema.minItems(1)); // Non-empty array
Schema.Array(Item).pipe(Schema.maxItems(10)); // Max 10 items
Schema.Array(Item).pipe(Schema.itemsCount(5)); // Exactly 5 items
Schema.NonEmptyArray(Item); // Non-empty array
// Built-in constrained types
Schema.NonEmptyString; // String with minLength(1)
Schema.Positive; // Number > 0
Schema.NonNegative; // Number >= 0
Schema.Int; // Integer
Custom Arbitrary Annotations (When Needed)
For complex constraints that Schema combinators can't express, use arbitrary annotation:
import { Schema, Arbitrary } from "effect";
import * as fc from "fast-check";
// Custom email generation (pattern too complex for generation)
const Email = Schema.String.pipe(
Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/),
Schema.annotations({
arbitrary: () => (fc) => fc.emailAddress(), // Use fast-check's generator
}),
);
// Custom UUID generation
const UserId = Schema.String.pipe(
Schema.annotations({
arbitrary: () => (fc) => fc.uuid(),
}),
);
// Custom date range
const BirthDate = Schema.Date.pipe(
Schema.annotations({
arbitrary: () => (fc) =>
fc.date({
min: new Date("1900-01-01"),
max: new Date("2010-01-01"),
}),
}),
);
Property Testing with it.prop
Use it.prop from @effect/vitest for property-based tests (see Testing Skill for full details):
import { it, expect } from "@effect/vitest";
import { Schema, Arbitrary } from "effect";
// Array form
it.prop("validates all generated users", [Arbitrary.make(User)], ([user]) => {
expect(user.age).toBeGreaterThanOrEqual(18);
expect(user.name.length).toBeGreaterThan(0);
});
// Object form
it.prop("round-trip preserves data", { user: Arbitrary.make(User) }, ({ user }) => {
const encoded = Schema.encodeSync(User)(user);
const decoded = Schema.decodeUnknownSync(User)(encoded);
expect(decoded).toEqual(user);
});
// Effect-based property test
it.effect.prop("processes all order states", [Arbitrary.make(Order)], ([order]) =>
Effect.gen(function* () {
const result = yield* processOrder(order);
expect(result).toBeDefined();
}),
);
4. Schema + Match Together
The most powerful pattern: TaggedClass for data, Schema.is() in Match for logic.
import { Schema, Match } from "effect";
// Define all variants with TaggedClass (not Struct)
class CreditCard extends Schema.TaggedClass<CreditCard>()("CreditCard", {
last4: Schema.String,
expiryMonth: Schema.Number,
expiryYear: Schema.Number,
}) {
get isExpired() {
const now = new Date();
return (
this.expiryYear < now.getFullYear() ||
(this.expiryYear === now.getFullYear() && this.expiryMonth < now.getMonth() + 1)
);
}
}
class BankTransfer extends Schema.TaggedClass<BankTransfer>()("BankTransfer", {
accountId: Schema.String,
routingNumber: Schema.String,
}) {}
class Crypto extends Schema.TaggedClass<Crypto>()("Crypto", {
walletAddress: Schema.String,
network: Schema.Literal("ethereum", "bitcoin", "solana"),
}) {}
const PaymentMethod = Schema.Union(CreditCard, BankTransfer, Crypto);
type PaymentMethod = Schema.Schema.Type<typeof PaymentMethod>;
// Process with Schema.is() to access class methods
const processPayment = (method: PaymentMethod, amount: number) =>
Match.value(method).pipe(
Match.when(Schema.is(CreditCard), (card) =>
card.isExpired ? Effect.fail("Card expired") : chargeCard(card.last4, amount),
),
Match.when(Schema.is(BankTransfer), (bank) => initiateBankTransfer(bank.accountId, bank.routingNumber, amount)),
Match.when(Schema.is(Crypto), (crypto) => sendCrypto(crypto.walletAddress, crypto.network, amount)),
Match.exhaustive,
);
// Also works with Match.tag for simple cases
const getPaymentLabel = (method: PaymentMethod) =>
Match.value(method).pipe(
Match.tag("CreditCard", (c) => `Card ending ${c.last4}`),
Match.tag("BankTransfer", (b) => `Bank ${b.accountId}`),
Match.tag("Crypto", (c) => `${c.network}: ${c.walletAddress.slice(0, 8)}...`),
Match.exhaustive,
);
Branded Types
Prevent mixing up values of the same underlying type:
import { Brand } from "effect";
// Define branded types
type UserId = string & Brand.Brand<"UserId">;
type OrderId = string & Brand.Brand<"OrderId">;
// Constructors
const UserId = Brand.nominal<UserId>();
const OrderId = Brand.nominal<OrderId>();
// Usage
const userId: UserId = UserId("user-123");
const orderId: OrderId = OrderId("order-456");
// Type error: can't assign UserId to OrderId
// const wrong: OrderId = userId
With Validation
import { Brand, Either } from "effect";
type Email = string & Brand.Brand<"Email">;
const Email = Brand.refined<Email>(
(s) => /^[^@]+@[^@]+\.[^@]+$/.test(s),
(s) => Brand.error(`Invalid email: ${s}`),
);
// Returns Either
const result = Email.either("test@example.com");
// Or throws
const email = Email("test@example.com");
With Schema
import { Schema } from "effect";
const UserId = Schema.String.pipe(Schema.brand("UserId"));
type UserId = Schema.Schema.Type<typeof UserId>;
const Email = Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/), Schema.brand("Email"));
Dual APIs
Most Effect functions support both styles:
Data-Last (Pipeable) - Recommended
import { Effect, pipe } from "effect";
// Using pipe
const result = pipe(
Effect.succeed(1),
Effect.map((n) => n + 1),
Effect.flatMap((n) => Effect.succeed(n * 2)),
);
// Using method chaining
const result = Effect.succeed(1).pipe(
Effect.map((n) => n + 1),
Effect.flatMap((n) => Effect.succeed(n * 2)),
);
Data-First
// Useful for single transformations
const mapped = Effect.map(Effect.succeed(1), (n) => n + 1);
Convention
- Use data-last for pipelines
- Use data-first for single operations
- Be consistent within a codebase
Generator Syntax (Effect.gen)
The preferred way to write sequential Effect code:
// Generator style - recommended
const program = Effect.gen(function* () {
const user = yield* getUser(id);
const orders = yield* getOrders(user.id);
const enriched = yield* enrichOrders(orders);
return { user, orders: enriched };
});
// Equivalent flatMap chain
const program = getUser(id).pipe(
Effect.flatMap((user) =>
getOrders(user.id).pipe(
Effect.flatMap((orders) => enrichOrders(orders).pipe(Effect.map((enriched) => ({ user, orders: enriched })))),
),
),
);
When to Use Effect.gen
- Sequential operations
- Complex control flow
- When readability matters
- Error handling with yield*
When to Use pipe
- Simple transformations
- Parallel operations
- Single-line operations
Do Notation (Simplifying Nesting)
Alternative to generators for some cases:
import { Effect } from "effect";
const program = Effect.Do.pipe(
Effect.bind("user", () => getUser(id)),
Effect.bind("orders", ({ user }) => getOrders(user.id)),
Effect.bind("enriched", ({ orders }) => enrichOrders(orders)),
Effect.map(({ user, enriched }) => ({ user, orders: enriched })),
);
Project Structure
Recommended Layout
src/
├── domain/ # Domain types and errors
│ ├── User.ts
│ ├── Order.ts
│ └── errors.ts
├── services/ # Service interfaces
│ ├── UserRepository.ts
│ └── OrderService.ts
├── implementations/ # Service implementations
│ ├── UserRepositoryLive.ts
│ └── OrderServiceLive.ts
├── layers/ # Layer composition
│ ├── AppLive.ts
│ └── TestLive.ts
├── http/ # HTTP handlers
│ └── routes.ts
└── main.ts # Entry point
Service Definition Pattern
// services/UserRepository.ts
import { Context, Effect } from "effect";
import { User, UserId } from "../domain/User";
import { UserNotFound } from "../domain/errors";
export class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
{
readonly findById: (id: UserId) => Effect.Effect<User, UserNotFound>;
readonly findByEmail: (email: string) => Effect.Effect<User, UserNotFound>;
readonly save: (user: User) => Effect.Effect<void>;
readonly delete: (id: UserId) => Effect.Effect<void>;
}
>() {}
Layer Composition Pattern
// layers/AppLive.ts
import { Layer } from "effect";
// Infrastructure
const InfraLive = Layer.mergeAll(DatabaseLive, HttpClientLive, LoggerLive);
// Repositories
const RepositoriesLive = Layer.mergeAll(UserRepositoryLive, OrderRepositoryLive).pipe(Layer.provide(InfraLive));
// Services
const ServicesLive = Layer.mergeAll(UserServiceLive, OrderServiceLive).pipe(Layer.provide(RepositoriesLive));
// Full application
export const AppLive = ServicesLive;
Naming Conventions
Types and Interfaces
// Domain types - PascalCase
interface User { ... }
interface Order { ... }
// Branded types - PascalCase
type UserId = string & Brand.Brand<"UserId">
type Email = string & Brand.Brand<"Email">
// Error types - PascalCase with descriptive suffix
class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", {...}) {}
class ValidationError extends Schema.TaggedError<ValidationError>()("ValidationError", {...}) {}
Services
// Service tag - PascalCase
class UserRepository extends Context.Tag("UserRepository")<...>() {}
// Layer implementations - PascalCase with Live/Test suffix
const UserRepositoryLive = Layer.effect(...)
const UserRepositoryTest = Layer.succeed(...)
Functions
// Effect-returning functions - camelCase
const getUser = (id: UserId): Effect.Effect<User, UserNotFound> => ...
const createOrder = (data: OrderData): Effect.Effect<Order, ValidationError> => ...
// Constructors - matching type name
const UserId = Brand.nominal<UserId>()
const User = (data: UserData): User => ...
Error Handling Style
Tagged Errors
import { Schema } from "effect";
// Always use Schema.TaggedError for domain errors
class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", { userId: Schema.String }) {}
class ValidationError extends Schema.TaggedError<ValidationError>()("ValidationError", {
field: Schema.String,
message: Schema.String,
}) {}
// Use in services
const getUser = (id: string): Effect.Effect<User, UserNotFound> =>
Effect.gen(function* () {
const user = yield* findInDb(id);
if (!user) {
return yield* Effect.fail(new UserNotFound({ userId: id }));
}
return user;
});
Error Recovery Pattern
const program = getUser(id).pipe(
// Specific error handling
Effect.catchTag("UserNotFound", (error) => Effect.succeed(defaultUser)),
// Or match all errors
Effect.catchTags({
UserNotFound: () => Effect.succeed(defaultUser),
ValidationError: (e) => Effect.fail(new BadRequest(e.message)),
}),
);
Best Practices Summary
Do
- ELIMINATE complex imperative logic - no
else if, nestedif, ternaries, for/while loops (simpleif/elseis OK) - Use Effect's data modules -
Array,Record,Struct,Tuplefor data manipulation - Use Effect's Predicate module -
Predicate.and,Predicate.or,Predicate.structfor composing predicates - Use Effect's Function module -
pipe,flow,identity,constant,compose - Refactor imperative code on sight - this is mandatory, not optional
- Use Schema.Class/TaggedClass - not Schema.Struct for domain entities
- Use tagged unions over optional properties - make states explicit
- Use Schema.is() in Match.when patterns - combine validation with matching
- Use Match for complex conditional logic - replace
else ifchains, nestedif, ternaries (simpleif/elseis OK) - Wrap ALL external dependencies in Services - API calls, databases, file I/O, third-party SDKs, email, caches, queues MUST go through
Context.Tagservices - Create Test Layers for every Service -
*Livefor production,*Testfor testing. This is required for 100% test coverage. - Use
@effect/vitestfor ALL tests -it.effect,it.scoped,it.live,it.layer,it.prop(see Testing Skill) - Use
Arbitrary.make(Schema)for ALL test data - Never hand-craft test objects - Define constraints in Schema, not fast-check filters -
Schema.between(),Schema.minLength(), etc. generate constrained values directly - Combine service test layers + Arbitrary - Services control I/O, Arbitrary generates data — together they enable 100% coverage
- Use Effect.gen for sequential code
- Define services with Context.Tag
- Compose layers bottom-up
- Use Schema.TaggedError for domain errors (works with Match.tag and Schema.is())
Don't - FORBIDDEN Patterns
- NEVER call external APIs/databases/file systems directly in business logic - always go through a
Context.Tagservice. Direct external calls make code untestable. - NEVER skip writing test Layers - every service MUST have a test layer. Without test layers, coverage is incomplete.
- NEVER use
else if- use Match.value + Match.when - NEVER nest
ifstatements - flatten with early return, or use Match - NEVER use ternary operators - use Match.value + Match.when, or simple if/else
- Prefer Match over
switch/case- but switch is acceptable as last resort - NEVER use
if (x != null)- always use Option.match - NEVER nest
Option.matchcalls - useOption.flatMapchains withOption.getOrElsewhen allonNonebranches return the same default - NEVER use for/while/do...while loops - use Effect's
Array.map/Array.filter/Array.reduceorEffect.forEach - NEVER use for...of/for...in loops - use Effect's
Arraymodule orEffectcombinators - NEVER mutate arrays (push/pop/splice) - use
Array.append,Array.prepend, spread, or immutable operations - NEVER reassign variables - use const and functional transformations
- NEVER use native
Array.prototype.find- useArray.findFirstwhich returnsOption - NEVER use native
array[index]- useArray.get(array, index)which returnsOption - NEVER use native
Object.keys/values/entries- useRecord.keys,Record.values,Record.toEntries - NEVER use native
record[key]- useRecord.get(record, key)which returnsOption - NEVER use manual
&&/||for predicates - usePredicate.and,Predicate.or,Predicate.not - NEVER check
.successor similar - always use Either.match or Effect.match - NEVER access
._tagdirectly - always use Match.tag or Schema.is() (for Schema types only) - NEVER extract
._tagas a type - e.g.,type Tag = Foo["_tag"]is forbidden - NEVER use
._tagin predicates - use Schema.is(Variant) with .some()/.filter() - NEVER use JSON.parse() - always use Schema.parseJson with a schema
- NEVER use Schema.Any or Schema.Unknown as type weakening - these are only permitted when the value is genuinely unconstrained at the domain level (e.g.,
causefield on error types capturing arbitrary caught exceptions, opaque pass-through payloads). If you can describe the data shape, define a proper schema instead. - Use Schema.Struct for domain entities (use Schema.Class)
- Use optional properties for state (use tagged unions)
- Use plain TypeScript interfaces/types without Schema
- Mix async/await with Effect (except at boundaries)
- Use bare try/catch (use Effect.try)
- Create services without layers
- Throw exceptions (use Effect.fail)
- NEVER use
Effect.runPromisein tests - useit.effectfrom@effect/vitest - NEVER import
itfromvitestin Effect test files - import from@effect/vitest - NEVER hand-craft test data - use
Arbitrary.make(Schema)orit.prop - NEVER use fast-check
.filter()- define constraints in Schema (Schema.between(),Schema.minLength(), etc.) so Arbitrary generates valid values directly
Related Skills
This skill covers high-level patterns and conventions. For detailed API usage and specific topics, consult these specialized skills:
CRITICAL: Always consult the Testing Skill when writing tests. It covers the full service-oriented testing pattern,
@effect/vitestAPIs (it.effect,it.prop,it.layer), test layers, and achieving 100% test coverage.
| Skill | Purpose | Key Topics |
|---|---|---|
| Testing | Effect testing patterns | @effect/vitest, it.effect, it.prop, test layers, service mocking, Arbitrary |
| Effect Core | Core Effect type and APIs | Creating Effects, Effect.gen, pipe, map, flatMap, running Effects |
| Error Management | Typed error handling | catchTag, catchAll, mapError, orElse, error accumulation |
| Pattern Matching | Match module APIs | Match.value, Match.type, Match.tag, Match.when, exhaustive matching |
| Schema | Data modeling and validation | Schema.Class, Schema.Struct, parsing, transformations, filters |
These skills work together: this Code Style skill defines the what (patterns to follow), while the specialized skills define the how (API details).
Additional Resources
For comprehensive code style documentation, consult ${CLAUDE_PLUGIN_ROOT}/references/llms-full.txt.
Search for these sections:
- "Branded Types" for nominal typing
- "Dual APIs" for function styles
- "Guidelines" for best practices
- "Simplifying Excessive Nesting" for do notation
More from andrueandersoncs/claude-skill-effect-ts
schema
This skill should be used when the user asks about "Effect Schema", "Schema.Struct", "Schema.decodeUnknown", "data validation", "parsing", "Schema.transform", "Schema filters", "Schema annotations", "JSON Schema", "Schema.Class", "Schema branded types", "encoding", "decoding", "Schema.parseJson", or needs to understand how Effect handles data validation and transformation.
13testing
This skill should be used when the user asks about "Effect testing", "@effect/vitest", "it.effect", "it.live", "it.scoped", "it.layer", "it.prop", "Schema Arbitrary", "property-based testing", "fast-check", "TestClock", "testing effects", "mocking services", "test layers", "TestContext", "Effect.provide test", "time testing", "Effect test utilities", "unit testing Effect", "generating test data", "flakyTest", "test coverage", "100% coverage", "service testing", "test doubles", "mock services", or needs to understand how to test Effect-based code.
13traits
This skill should be used when the user asks about "Effect Equal", "Effect Hash", "Equivalence", "Order", "structural equality", "custom equality", "comparing objects", "sorting", "Equal.equals", "Hash.hash", "Equivalence.make", "Order.lessThan", "comparable types", or needs to understand how Effect handles equality, hashing, and ordering of values.
12configuration
This skill should be used when the user asks about "Effect Config", "environment variables", "configuration management", "Config.string", "Config.number", "ConfigProvider", "Config.nested", "Config.withDefault", "Config.redacted", "sensitive values", "config validation", "loading config from JSON", "config schema", or needs to understand how Effect handles application configuration.
12concurrency
This skill should be used when the user asks about "Effect concurrency", "fibers", "Fiber", "forking", "Effect.fork", "Effect.forkDaemon", "parallel execution", "Effect.all concurrency", "Deferred", "Queue", "PubSub", "Semaphore", "Latch", "fiber interruption", "Effect.race", "Effect.raceAll", "concurrent effects", or needs to understand how Effect handles parallel and concurrent execution.
11observability
This skill should be used when the user asks about "Effect logging", "Effect.log", "Effect metrics", "Effect tracing", "spans", "telemetry", "Metric.counter", "Metric.gauge", "Metric.histogram", "OpenTelemetry", "structured logging", "log levels", "Effect.logDebug", "Effect.logInfo", "Effect.logWarning", "Effect.logError", or needs to understand how Effect handles logging, metrics, and distributed tracing.
10