| name | code-style |
| description | This skill should be used EVERY TIME you're writing TypeScript with Effect, especially when the user asks about "Effect best practices", "Effect code style", "idiomatic Effect", "functional programming", "no loops", "no for loops", "avoid imperative", "Effect Array", "Effect Record", "Effect Struct", "Effect Tuple", "Effect Predicate", "Schema-first", "Match-first", "when to use Schema", "when to use Match", "branded types", "dual APIs", "Effect guidelines", "do notation", "Effect.gen", "pipe vs method chaining", "Effect naming conventions", "Effect project structure", "data modeling in Effect", or needs to understand idiomatic Effect-TS patterns and conventions. |
| version | 1.1.0 |
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 if chains, nested if statements, ternary operators
- Simple
if/else is allowed: A single if with optional else (no nesting, no else if)
switch/case as last resort: Prefer Match.type/Match.value, but switch is 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
Array module (Array.map, Array.filter, Array.reduce, Array.flatMap, Array.filterMap, etc.)
- Effect's
Record module (Record.map, Record.filter, Record.get, Record.keys, Record.values, etc.)
- Effect's
Struct module (Struct.pick, Struct.omit, Struct.evolve, Struct.get, etc.)
- Effect's
Tuple module (Tuple.make, Tuple.getFirst, Tuple.mapBoth, etc.)
- Effect's
Predicate module (Predicate.and, Predicate.or, Predicate.not, Predicate.struct, etc.)
- Effect combinators (
Effect.forEach, Effect.all, Effect.reduce)
- Effect's
Function module (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, no else if)
else if chains → Match.value + Match.when (FORBIDDEN as else if)
- Nested
if statements → Flatten with early return, or Match.value + Match.when
switch/case statements → Prefer Match.type + Match.tag, but switch is acceptable
- Ternary operators (
? :) → Match.value + Match.when or simple if/else
- Single optional value →
Option.match
- Chained optional operations →
Option.flatMap + Option.getOrElse
- Result/error conditionals →
Either.match or Effect.match
if (user.isAdmin) {
return grantFullAccess()
}
return grantLimitedAccess()
if (isValid) {
process(data)
} else {
handleError()
}
if (user.role === "admin") {
return "full access"
} else if (user.role === "user") {
return "limited access"
} else {
return "no access"
}
if (user.isActive) {
if (user.isAdmin) {
return "active admin"
}
}
const message = isError ? "Failed" : "Success"
if (event._tag === "UserCreated") { ... }
const isCreated = event._tag === "UserCreated"
type ConflictTag = Conflict["_tag"]
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
const hasConflict = conflicts.some(Schema.is(MergeConflict))
const mergeConflicts = conflicts.filter(Schema.is(MergeConflict))
const countMerge = conflicts.filter(Schema.is(MergeConflict)).length
const getAccess = (user: User) =>
Match.value(user.role).pipe(
Match.when("admin", () => "full access"),
Match.when("user", () => "limited access"),
Match.orElse(() => "no access")
)
const getStatusMessage = Match.type<Status>().pipe(
Match.when("pending", () => "waiting"),
Match.when("active", () => "running"),
Match.exhaustive
)
switch (status) {
case "pending": return "waiting"
case "active": return "running"
default: return "unknown"
}
const displayName = Option.match(maybeUser, {
onNone: () => "Guest",
onSome: (user) => user.name
})
const name = pipe(
findUser(id),
Option.match({
onNone: () => "Unknown",
onSome: (user) =>
Option.match(user.profile, {
onNone: () => "Unknown",
onSome: (profile) => profile.displayName,
}),
}),
)
const name = pipe(
findUser(id),
Option.flatMap((user) => user.profile),
Option.map((profile) => profile.displayName),
Option.getOrElse(() => "Unknown"),
)
const result = Either.match(parseResult, {
onLeft: (error) => `Error: ${error}`,
onRight: (value) => `Success: ${value}`
})
const handleEvent = Match.type<AppEvent>().pipe(
Match.tag("UserCreated", (e) => notifyAdmin(e.userId)),
Match.tag("UserDeleted", (e) => cleanupData(e.userId)),
Match.exhaustive
)
if (Schema.is(UserCreated)(event)) {
}
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.findFirst returns Option<A> instead of A | undefined
Array.get returns Option<A> for safe indexing
Array.filterMap combines filter and map in one pass
Array.partition returns typed tuple [excluded, satisfying]
Array.groupBy returns Record<K, NonEmptyArray<A>>
Array.match provides exhaustive empty/non-empty handling
- All functions work with
pipe for composable pipelines
- Consistent dual API (data-first and data-last)
import { Array, pipe } from "effect";
const doubled = [];
for (let i = 0; i < numbers.length; i++) {
doubled.push(numbers[i] * 2);
}
const results = [];
for (const item of items) {
results.push(process(item));
}
let sum = 0;
let i = 0;
while (i < numbers.length) {
sum += numbers[i];
i++;
}
const output = [];
items.forEach((item) => output.push(transform(item)));
const doubled = Array.map(numbers, (n) => n * 2);
const doubled = pipe(
numbers,
Array.map((n) => n * 2),
);
const adults = Array.filter(users, (u) => u.age >= 18);
const sum = Array.reduce(numbers, 0, (acc, n) => acc + n);
const allTags = Array.flatMap(posts, (post) => post.tags);
const admin = Array.findFirst(users, (u) => u.role === "admin");
const hasAdmin = Array.some(users, (u) => u.role === "admin");
const allVerified = Array.every(users, (u) => u.verified);
const validEmails = Array.filterMap(users, (u) => (isValidEmail(u.email) ? Option.some(u.email) : Option.none()));
const [minors, adults] = Array.partition(users, (u) => u.age >= 18);
const usersByRole = Array.groupBy(users, (u) => u.role);
const uniqueIds = Array.dedupe(ids);
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";
const doubled = Record.map(prices, (price) => price * 2);
const expensive = Record.filter(prices, (price) => price > 100);
const price = Record.get(prices, "item1");
const allKeys = Record.keys(config);
const allValues = Record.values(config);
const record = Record.fromEntries([
["a", 1],
["b", 2],
]);
const entries = Record.toEntries(record);
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";
const namePart = Struct.pick(user, "firstName", "lastName");
const publicUser = Struct.omit(user, "password", "ssn");
const updated = Struct.evolve(user, {
age: (age) => age + 1,
name: (name) => name.toUpperCase(),
});
const getName = Struct.get("name");
const name = getName(user);
Tuple operations - use Effect's Tuple module:
import { Tuple } from "effect";
const pair = Tuple.make("key", 42);
const key = Tuple.getFirst(pair);
const value = Tuple.getSecond(pair);
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,
});
const first = Tuple.at(tuple, 0);
Predicate operations - use Effect's Predicate module:
import { Predicate } from "effect";
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);
const isValidUser = Predicate.struct({
name: Predicate.isString,
age: Predicate.isNumber,
});
const isStringNumberPair = Predicate.tuple(Predicate.isString, Predicate.isNumber);
Predicate.isString(value);
Predicate.isNumber(value);
Predicate.isNullable(value);
Predicate.isNotNullable(value);
Predicate.isRecord(value);
Effect loops - use Effect combinators:
const processAll = Effect.gen(function* () {
const results = [];
for (const item of items) {
const result = yield* processItem(item);
results.push(result);
}
return results;
});
const processAll = Effect.forEach(items, processItem);
const results = Effect.all(effects);
const results = Effect.all(effects, { concurrency: 10 });
const total = Effect.reduce(items, 0, (acc, item) => getPrice(item).pipe(Effect.map((price) => acc + price)));
const processed = Stream.fromIterable(items).pipe(Stream.mapEffect(processItem), Stream.runCollect);
Recursion for complex iteration:
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;
};
const collectLeaves = (node: TreeNode): ReadonlyArray<TreeNode> =>
Array.match(node.children, {
onEmpty: () => [node],
onNonEmpty: (children) => Array.flatMap(children, collectLeaves),
});
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";
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);
const isActive = <T extends { active: boolean }>(item: T) => item.active;
const getEmail = (user: User) => user.email;
const getTotal = (order: Order) => order.total;
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));
const getActiveEmails = flow(Array.filter(isActive<User>), Array.map(getEmail));
const getActiveTotals = flow(Array.filter(isActive<Order>), Array.map(getTotal));
const parseAndValidate = Function.compose(parse, validate);
const transform = shouldTransform ? myTransform : Function.identity;
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";
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];
}
}
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>;
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,
);
const Order = Schema.Struct({
orderId: Schema.String,
items: Schema.Array(Schema.String),
trackingNumber: Schema.optional(Schema.String),
shippedAt: Schema.optional(Schema.Date),
deliveredAt: Schema.optional(Schema.Date),
});
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";
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,
);
const toHttpStatus = Match.type<AppError>().pipe(
Match.tag("NotFound", () => 404),
Match.tag("Unauthorized", () => 401),
Match.tag("ValidationError", () => 400),
Match.tag("InternalError", () => 500),
Match.exhaustive,
);
const displayUser = (maybeUser: Option<User>) =>
Option.match(maybeUser, {
onNone: () => "Guest user",
onSome: (user) => `Welcome, ${user.name}`,
});
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.prop from @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";
const badArbitrary = fc.integer().filter((n) => n >= 18 && n <= 100);
const UserArbitrary = Arbitrary.make(User);
const badFiltered = UserArbitrary(fc).filter((u) => u.age >= 18);
const Age = Schema.Number.pipe(
Schema.int(),
Schema.between(18, 100),
);
class User extends Schema.Class<User>("User")({
id: Schema.String.pipe(Schema.minLength(1)),
name: Schema.String.pipe(Schema.nonEmptyString()),
age: Age,
}) {}
const UserArbitrary = Arbitrary.make(User);
fc.sample(UserArbitrary(fc), 5);
Common Schema Constraints for Arbitrary Generation
Use these Schema combinators to constrain generation (never filter):
import { Schema } from "effect";
Schema.Number.pipe(Schema.int());
Schema.Number.pipe(Schema.positive());
Schema.Number.pipe(Schema.nonNegative());
Schema.Number.pipe(Schema.between(1, 100));
Schema.Number.pipe(Schema.greaterThan(0));
Schema.Number.pipe(Schema.lessThanOrEqualTo(100));
Schema.String.pipe(Schema.minLength(1));
Schema.String.pipe(Schema.maxLength(100));
Schema.String.pipe(Schema.length(10));
Schema.String.pipe(Schema.nonEmptyString());
Schema.String.pipe(Schema.pattern(/^[A-Z]{3}$/));
Schema.Array(Item).pipe(Schema.minItems(1));
Schema.Array(Item).pipe(Schema.maxItems(10));
Schema.Array(Item).pipe(Schema.itemsCount(5));
Schema.NonEmptyArray(Item);
Schema.NonEmptyString;
Schema.Positive;
Schema.NonNegative;
Schema.Int;
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";
const Email = Schema.String.pipe(
Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/),
Schema.annotations({
arbitrary: () => (fc) => fc.emailAddress(),
}),
);
const UserId = Schema.String.pipe(
Schema.annotations({
arbitrary: () => (fc) => fc.uuid(),
}),
);
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";
it.prop("validates all generated users", [Arbitrary.make(User)], ([user]) => {
expect(user.age).toBeGreaterThanOrEqual(18);
expect(user.name.length).toBeGreaterThan(0);
});
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);
});
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";
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>;
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,
);
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";
type UserId = string & Brand.Brand<"UserId">;
type OrderId = string & Brand.Brand<"OrderId">;
const UserId = Brand.nominal<UserId>();
const OrderId = Brand.nominal<OrderId>();
const userId: UserId = UserId("user-123");
const orderId: OrderId = OrderId("order-456");
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}`),
);
const result = Email.either("test@example.com");
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";
const result = pipe(
Effect.succeed(1),
Effect.map((n) => n + 1),
Effect.flatMap((n) => Effect.succeed(n * 2)),
);
const result = Effect.succeed(1).pipe(
Effect.map((n) => n + 1),
Effect.flatMap((n) => Effect.succeed(n * 2)),
);
Data-First
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:
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 };
});
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
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
import { Layer } from "effect";
const InfraLive = Layer.mergeAll(DatabaseLive, HttpClientLive, LoggerLive);
const RepositoriesLive = Layer.mergeAll(UserRepositoryLive, OrderRepositoryLive).pipe(Layer.provide(InfraLive));
const ServicesLive = Layer.mergeAll(UserServiceLive, OrderServiceLive).pipe(Layer.provide(RepositoriesLive));
export const AppLive = ServicesLive;
Naming Conventions
Types and Interfaces
interface User { ... }
interface Order { ... }
type UserId = string & Brand.Brand<"UserId">
type Email = string & Brand.Brand<"Email">
class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", {...}) {}
class ValidationError extends Schema.TaggedError<ValidationError>()("ValidationError", {...}) {}
Services
class UserRepository extends Context.Tag("UserRepository")<...>() {}
const UserRepositoryLive = Layer.effect(...)
const UserRepositoryTest = Layer.succeed(...)
Functions
const getUser = (id: UserId): Effect.Effect<User, UserNotFound> => ...
const createOrder = (data: OrderData): Effect.Effect<Order, ValidationError> => ...
const UserId = Brand.nominal<UserId>()
const User = (data: UserData): User => ...
Error Handling Style
Tagged Errors
import { Schema } from "effect";
class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", { userId: Schema.String }) {}
class ValidationError extends Schema.TaggedError<ValidationError>()("ValidationError", {
field: Schema.String,
message: Schema.String,
}) {}
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(
Effect.catchTag("UserNotFound", (error) => Effect.succeed(defaultUser)),
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, nested if, ternaries, for/while loops (simple if/else is OK)
- Use Effect's data modules -
Array, Record, Struct, Tuple for data manipulation
- Use Effect's Predicate module -
Predicate.and, Predicate.or, Predicate.struct for 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 if chains, nested if, ternaries (simple if/else is OK)
- Wrap ALL external dependencies in Services - API calls, databases, file I/O, third-party SDKs, email, caches, queues MUST go through
Context.Tag services
- Create Test Layers for every Service -
*Live for production, *Test for testing. This is required for 100% test coverage.
- Use
@effect/vitest for 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.Tag service. 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
if statements - 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.match calls - use Option.flatMap chains with Option.getOrElse when all onNone branches return the same default
- NEVER use for/while/do...while loops - use Effect's
Array.map/Array.filter/Array.reduce or Effect.forEach
- NEVER use for...of/for...in loops - use Effect's
Array module or Effect combinators
- 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 - use Array.findFirst which returns Option
- NEVER use native
array[index] - use Array.get(array, index) which returns Option
- NEVER use native
Object.keys/values/entries - use Record.keys, Record.values, Record.toEntries
- NEVER use native
record[key] - use Record.get(record, key) which returns Option
- NEVER use manual
&&/|| for predicates - use Predicate.and, Predicate.or, Predicate.not
- NEVER check
.success or similar - always use Either.match or Effect.match
- NEVER access
._tag directly - always use Match.tag or Schema.is() (for Schema types only)
- NEVER extract
._tag as a type - e.g., type Tag = Foo["_tag"] is forbidden
- NEVER use
._tag in 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.,
cause field 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.runPromise in tests - use it.effect from @effect/vitest
- NEVER import
it from vitest in Effect test files - import from @effect/vitest
- NEVER hand-craft test data - use
Arbitrary.make(Schema) or it.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/vitest APIs (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