Code Style
This skill should be used when the user asks about "Effect best practices", "Effect code style", "idiomatic Effect", "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",...
SKILL.md
Full skill instructions
Code Style in Effect
Overview
Effect's idiomatic style centers on two core principles:
- 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 Control Flow
NEVER use if/else, switch/case, or ternary operators. These imperative constructs must be replaced with pattern matching in ALL cases:
if/elsechains →Match.value+Match.whenswitch/casestatements →Match.type+Match.tagorMatch.when- Ternary operators (
? :) →Match.value+Match.when - Optional chaining conditionals →
Option.match - Result/error conditionals →
Either.matchorEffect.match
// ❌ FORBIDDEN: if/else
if (user.role === "admin") {
return "full access"
} else if (user.role === "user") {
return "limited access"
} else {
return "no access"
}
// ❌ FORBIDDEN: switch/case
switch (status) {
case "pending": return "waiting"
case "active": return "running"
default: return "unknown"
}
// ❌ 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
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
const getStatusMessage = Match.type<Status>().pipe(
Match.when("pending", () => "waiting"),
Match.when("active", () => "running"),
Match.exhaustive
)
// ✅ REQUIRED: Option.match for nullable/optional
const displayName = Option.match(maybeUser, {
onNone: () => "Guest",
onSome: (user) => user.name
})
// ✅ 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 imperative control flow in existing code, refactor it immediately. This is not optional - imperative conditionals are code smells 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/results - use Match
const displayUser = Match.type<Option<User>>().pipe(
Match.tag("Some", ({ value }) => `Welcome, ${value.name}`),
Match.tag("None", () => "Guest user"),
Match.exhaustive
)
// 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.10),
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. 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("[email protected]")
// Or throws
const email = Email("[email protected]")
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 all if/else, switch/case, and ternaries - use Match, Option.match, Either.match instead
- 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 ALL conditional logic - replace if/else, switch, ternaries
- 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 - Use
Arbitrary.make(Schema)for ALL test data - Never hand-craft test objects - 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 if/else - always use Match.value + Match.when
- NEVER use switch/case - always use Match.type + Match.tag
- NEVER use ternary operators - always use Match.value + Match.when
- NEVER use
if (x != null)- always use Option.match - 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
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
