
Effect Review
- 73 installs
- 78 repo stars
- Updated July 11, 2026
- makisuo/skills
Helps with ai & agent building tasks.
About
effect-review is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- effect-review
- AI & Agent Building
- AI-coding skill
Effect Review by the numbers
- 73 all-time installs (skills.sh)
- Ranked #5,587 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/makisuo/skills --skill effect-reviewAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 73 |
|---|---|
| repo stars | ★ 78 |
| Last updated | July 11, 2026 |
| Repository | makisuo/skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Effect-TS Code Review
Orchestrate a multi-agent review of code changes against Effect-TS best practices.
Workflow
Step 1: Discover Changed Files
Run git diff --name-only main...HEAD to find all changed files on the current branch. If that fails (e.g., on main), fall back to git diff --name-only HEAD~1 or git diff --name-only for unstaged changes.
List the changed files for the user.
Step 2: Categorize Files
Split files into categories:
- Backend Effect files:
.tsfiles NOT ending in.test.ts, NOT config files (.config.ts,tsconfig, etc.), NOT UI component library directories - Test files:
.test.tsfiles - UI files:
.tsxfiles - Skip:
.md,.json,.yml,.css, config files, generated files
Step 3: Launch Sub-Agents in Parallel
Based on which categories have files, launch the appropriate agents using the Agent tool. Launch all applicable agents in a single message for maximum parallelism.
If backend Effect files exist, launch these 4 agents in parallel:
effect-primitives-reviewer— checks Effect primitives (Array, Match, Option, forEach, no try/catch, no async/await, Layer not Effect.provide)branded-types-reviewer— checks branded type usage for all entity IDsotel-reviewer— checks tracing setup (Effect.fn trace names, annotateCurrentSpan, structured logging)error-reviewer— checks error definitions and handling (Schema.TaggedError, catchTag, rich context)typescript-reviewer— checks TypeScript patterns (noas any, prefersatisfiesoveras, no manual type annotations on inferred types)
If test files exist, launch:
test-coverage-reviewer— checks @effect/vitest patterns and assesses coverage gaps
If UI files exist, launch:
ui-reviewer— checks component library usage, accessibility, layout, brand consistency
For each agent, provide the prompt:
Review the following files for [agent's specialty]. Read each file and produce a structured report with Critical/Warning/Info findings.
>
Files to review:
- [list of file paths]
>
Also review the reference guide at references/[relevant-reference].md (relative to this skill) for the detailed checklist.Step 4: Unified Report
After all agents complete, compile results into a single report:
# Effect Review Report
## Effect Primitives
[agent output]
## Branded Types
[agent output]
## OTEL / Observability
[agent output]
## Error Handling
[agent output]
## TypeScript Patterns
[agent output]
## Test Coverage
[agent output]
## UI Quality
[agent output]
---
## Summary
| Category | Critical | Warning | Info |
|----------|----------|---------|------|
| Primitives | X | Y | Z |
| Branded Types | X | Y | Z |
| OTEL | X | Y | Z |
| Errors | X | Y | Z |
| TypeScript | X | Y | Z |
| Tests | X | Y | Z |
| UI | X | Y | Z |
| **Total** | **X** | **Y** | **Z** |
**Verdict**: PASS / NEEDS WORK / FAIL
**Score: X/10**- PASS: 0 critical findings
- NEEDS WORK: 1-3 critical findings
- FAIL: 4+ critical findings
Scoring (0-10)
After compiling all findings, assign an overall score from 0 to 10:
- 10: Perfect — no findings at all, exemplary Effect-TS code
- 9: Excellent — only minor info-level suggestions
- 8: Great — a few warnings, no criticals
- 7: Good — several warnings but no criticals
- 6: Acceptable — 1 critical or many warnings
- 5: Needs work — 2-3 criticals
- 4: Below standard — 4-5 criticals
- 3: Poor — 6+ criticals or fundamental pattern violations
- 2: Very poor — majority of code ignores Effect patterns
- 1: Minimal compliance — almost no Effect patterns followed
- 0: No compliance — entirely non-Effect code submitted as Effect code
Display the score prominently at the end of the report.
Reference Files
Detailed checklists with codebase-specific examples:
references/effect-primitives.md— Effect Array, Match, Option, forEach, Schema, Layerreferences/branded-types.md— Branded type usage and known types listreferences/otel-patterns.md— Tracing, span annotations, structured loggingreferences/error-patterns.md— Schema.TaggedError, catchTag, error contextreferences/typescript-patterns.md— Noas any, prefersatisfiesoveras, no manual type annotationsreferences/test-patterns.md— @effect/vitest, it.layer, coverage assessmentreferences/effect-atom-patterns.md— Effect-Atom React patterns, queries, mutations, Result.builder
Branded Types Checklist
Core Rule
All entity IDs MUST use branded types via Schema.brand(). Never use plain string or number for IDs. This prevents mixing up IDs of different entity types at compile time.
How to Define Branded Types
import { Schema } from "effect"
// Integer IDs
export const UserId = Schema.Int.pipe(Schema.brand("@myorg/schema/UserId"))
export type UserId = Schema.Schema.Type<typeof UserId>
// String IDs
export const ApiKey = Schema.String.pipe(Schema.brand("@myorg/schema/ApiKey"))
export type ApiKey = Schema.Schema.Type<typeof ApiKey>
// FromString variants (for URL params that arrive as strings)
export const UserIdFromString = Schema.NumberFromString.pipe(
Schema.brand("@myorg/schema/UserId")
)Define branded types for every entity ID in your domain (e.g., UserId, OrganizationId, ProjectId, OrderId, ProductId, etc.) and colocate them in a shared branded types module.
Checklist
1. Function Parameters Use Branded Types Directly
// GOOD
const findById = (userId: UserId, organizationId: OrganizationId) => ...
// BAD
const findById = (userId: number, organizationId: number) => ...2. No as Casting for IDs
// GOOD
import { Schema } from "effect"
const id = Schema.decodeSync(UserId)(rawValue)
// or use the branded constructor
const id = UserId.make(rawValue)
// BAD
const id = rawValue as UserId
const id = someNumber as unknown as UserId3. *FromString Variants for URL/Route Params
URL params arrive as strings. Use *FromString schemas to decode them.
// GOOD
const params = Schema.Struct({
userId: UserIdFromString,
projectId: ProjectIdFromString,
})
// BAD
const userId = parseInt(req.params.userId) as UserId4. Database Schema Alignment
When a database column uses a branded type, all code accessing that column must use the same branded type throughout. Check that:
- Repository method params match column types
- Service method params propagate branded types (not plain numbers)
- API handler params decode to branded types before passing to services
5. No Plain string/number in Domain Types
// GOOD
interface OrderConfig {
orderId: OrderId
userId: UserId
}
// BAD
interface OrderConfig {
orderId: number
userId: number
}Where to Check
- Function signatures (params and return types)
- Interface/type definitions containing IDs
- Schema definitions for API input/output
- Database query
.where()clauses - Variable declarations storing IDs
Effect-Atom Patterns Checklist
Effect-Atom (@effect-atom/atom-react) is the standard way to handle data fetching, mutations, and server state in React components in this codebase. If new UI code is NOT using Effect-Atom, recommend adopting it.
Package & Imports
import { Atom, AtomHttpApi, Result, Registry, RegistryContext } from "@effect-atom/atom-react"
import { AtomRpc } from "@effect-atom/atom-react"
import { useAtom, useAtomValue, useAtomSet, useAtomRefresh } from "@effect-atom/atom-react"1. Use Typesafe Clients (AtomHttpApi or AtomRpc)
API calls to our own Effect HttpApi or Effect RPC backends should go through a typesafe client — AtomHttpApi.Tag for Effect HttpApi backends or AtomRpc.Tag for Effect RPC backends. This gives end-to-end type safety from the server schema to the client. Raw fetch/axios is acceptable for third-party/external APIs or non-Effect backends.
HTTP API Client (AtomHttpApi)
For REST-style APIs defined with HttpApi from @effect/platform:
// Define a typesafe client bound to your API schema
export class ApiClient extends AtomHttpApi.Tag<ApiClient>()(
"@myorg/web/ApiClient",
{
api: MyApi, // Your HttpApi definition (provides full type safety)
httpClient: AuthClient, // Pre-configured HTTP client with auth
baseUrl: API_BASE,
}
) {}
// Queries and mutations are fully typed from the API schema
export const usersAtom = ApiClient.query("users", "listUsers", { ... })
export const createUserMutation = ApiClient.mutation("users", "createUser")RPC Client (AtomRpc)
For Effect RPC services — provides end-to-end type safety from server to client:
export class UsersClient extends AtomRpc.Tag<UsersClient>()(
"@myorg/web/UsersClient",
{
group: UsersRpcGroup,
protocol: makeProtocolLayer("/rpc/users"),
}
) {}What to Flag
Only flag raw fetch/axios when targeting our own Effect HttpApi or Effect RPC backends where a typesafe client can derive types from the server schema. Calls to external APIs or non-Effect backends are fine.
// BAD — raw fetch to our own API, no type safety
const res = await fetch("/api/users", { method: "POST", body: JSON.stringify(data) })
const users = await res.json() // `any` type, no validation
// FINE — external third-party API, we don't control it
const res = await fetch("https://api.stripe.com/v1/customers", { ... })
// GOOD — typesafe client for our own API
export const usersAtom = ApiClient.query("users", "listUsers", { timeToLive: "5 minutes" })
export const createUserMutation = ApiClient.mutation("users", "createUser")2. Query Atoms for Data Fetching
Data fetching should use AtomHttpApi.query() or an RPC client, NOT useState + useEffect + fetch, and NOT React Query/SWR.
// GOOD — query atom with TTL
export const usersAtom = ApiClient.query("users", "listUsers", {
timeToLive: "5 minutes",
})
// Parameterized query — function returning atom
export const userAtom = (userId: UserId) =>
ApiClient.query("users", "getUser", {
urlParams: { user_id: userId },
timeToLive: "3 minutes",
})
// BAD — manual fetch with useState/useEffect
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch("/api/users").then(res => res.json()).then(setUsers).finally(() => setLoading(false))
}, [])3. Mutation Atoms for Write Operations
Mutations should use AtomHttpApi.mutation().
// GOOD
export const createUserMutation = ApiClient.mutation("users", "createUser")
export const deleteUserMutation = ApiClient.mutation("users", "deleteUser")
// BAD — manual fetch POST
const handleCreate = async () => {
const res = await fetch("/api/users", { method: "POST", body: JSON.stringify(data) })
}4. Consuming Queries — useAtomValue + Result.builder
Read query results with useAtomValue. Render states with Result.builder().
// GOOD
function UsersList() {
const result = useAtomValue(usersAtom)
return Result.builder(result)
.onInitialOrWaiting(() => <Loading />)
.onFailure((error) => <ErrorDisplay message={String(error)} />)
.onSuccess((response) => (
<ul>{response.data.map(u => <li key={u.id}>{u.name}</li>)}</ul>
))
.render()
}
// BAD — manual state matching
function UsersList() {
const result = useAtomValue(usersAtom)
if (result.waiting) return <Loading />
if (Result.isFailure(result)) return <Error />
return <ul>...</ul>
}5. Consuming Mutations — useAtom with Promise Mode
Use useAtom(mutation, { mode: "promise" }) for mutations. Derive loading from result.waiting, NOT useState.
// GOOD
const [result, mutate] = useAtom(createUserMutation, { mode: "promise" })
const isLoading = result.waiting
const handleSubmit = async () => {
try {
await mutate({ payload: formData })
onSuccess?.()
} catch (err) {
showError(err)
}
}
<Button disabled={isLoading}>
{isLoading ? "Creating..." : "Create"}
</Button>
// BAD — useState for loading
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = async () => {
setIsLoading(true)
try { await mutate({ payload }) } finally { setIsLoading(false) }
}6. useAtomSet for Fire-and-Forget Mutations
When you only need the mutate function without tracking result state:
// GOOD — multiple mutations, no state tracking needed
const setCreate = useAtomSet(createMutation, { mode: "promise" })
const setUpdate = useAtomSet(updateMutation, { mode: "promise" })7. Cache Invalidation — useAtomRefresh
Invalidate query caches after mutations with useAtomRefresh. NOT manual refetch logic.
// GOOD
const refreshUsers = useAtomRefresh(usersAtom)
const handleCreate = async () => {
await mutate({ payload })
refreshUsers() // Invalidate and refetch
}
// BAD — manual refetch state
const [refetchKey, setRefetchKey] = useState(0)
const handleCreate = async () => {
await createUser(data)
setRefetchKey(k => k + 1) // Force re-render
}8. Derived/Computed Atoms — Atom.make
Combine multiple atoms into derived state with Atom.make():
// GOOD
export const dashboardAtom = (appId: ApplicationId) => {
const usersAtom = chartDataAtom(appId, { metric: "users" })
const revenueAtom = chartDataAtom(appId, { metric: "revenue" })
return Atom.make((get) => {
const combined = Result.all({
users: get(usersAtom),
revenue: get(revenueAtom),
})
return Result.map(combined, ({ users, revenue }) => ({
totalUsers: users.total,
totalRevenue: revenue.total,
}))
})
}9. Dialog Pattern
Dialogs own their mutation hooks internally. Parent passes data props and onOpenChange/onSuccess callbacks.
// GOOD — dialog owns mutation
function CreateUserDialog({ open, onOpenChange, organizationId }: Props) {
const [result, mutate] = useAtom(createUserMutation, { mode: "promise" })
const isLoading = result.waiting
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<form onSubmit={() => mutate({ payload: { organizationId, ...formData } })}>
<Button disabled={isLoading}>{isLoading ? "Creating..." : "Create"}</Button>
</form>
</Dialog>
)
}
// BAD — parent passes mutation handler
function CreateUserDialog({ open, onOpenChange, onSubmit, isLoading }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<form onSubmit={onSubmit}>
<Button disabled={isLoading}>Create</Button>
</form>
</Dialog>
)
}What to Flag
- Critical: Raw
fetch/axioscalls to our own Effect HttpApi/RPC backends without a typesafe client (should useAtomHttpApiorAtomRpc). External APIs or non-Effect backends are fine. - Critical:
useState+useEffect+fetchfor data fetching (should use Effect-Atom query atoms) - Critical:
useStatefor loading state when an atom mutation is available (result.waiting) - Warning: Manual if/else for result states instead of
Result.builder() - Warning: Missing
useAtomRefreshafter mutations that affect visible queries - Info: Recommend Effect-Atom if component uses React Query, SWR, or manual fetch patterns
Effect Primitives Checklist
1. Effect Array/HashMap Over Native
Use Array from effect for functional array operations in Effect code. Use HashMap for key-value lookups instead of plain objects or Map.
// GOOD
import { Array, HashMap } from "effect"
const ids = Array.map(items, (item) => item.id)
const lookup = HashMap.fromIterable(items.map((i) => [i.id, i]))
// BAD
const ids = items.map((item) => item.id) // native .map in Effect service code2. Effect.forEach Over For Loops
Any loop body that performs an Effect must use Effect.forEach. Supports concurrency option for parallel execution.
// GOOD
yield* Effect.forEach(users, (user) => sendNotification(user), { concurrency: 5 })
// BAD
for (const user of users) {
yield* sendNotification(user)
}3. Match Over Switch Statements
All switch statements on discriminated unions or string literals should use Match.value() or Match.type<T>() with Match.exhaustive.
// GOOD
import { Match } from "effect"
const result = Match.value(platform).pipe(
Match.when("IOS", () => "ios" as const),
Match.when("ANDROID", () => "android" as const),
Match.exhaustive
)
// BAD
switch (platform) {
case "IOS": return "ios"
case "ANDROID": return "android"
}4. Option Over Optional Chaining
In Effect services and domain types, prefer Option<T> over T | null | undefined. Use Option.match, Option.map, Option.getOrElse instead of ?. chains.
// GOOD
import { Option } from "effect"
const name = Option.match(user.displayName, {
onNone: () => "Anonymous",
onSome: (name) => name
})
// BAD
const name = user?.displayName ?? "Anonymous" // in Effect service codeNote: ?. is acceptable in React components and non-Effect utility code. Flag it only in Effect services/repositories/handlers.
5. Effect Schema Over Zod/Manual Validation
All runtime validation should use Schema from effect. No Zod imports. No manual typeof/instanceof guards for data parsing.
// GOOD
import { Schema } from "effect"
const UserInput = Schema.Struct({
name: Schema.String,
email: Schema.String.pipe(Schema.pattern(/@/)),
})
// BAD
import { z } from "zod"
const UserInput = z.object({ name: z.string() })6. Layer.provide Not Effect.provide
Dependencies should be composed via Layer in service definitions, not via Effect.provide at call sites. Exception: infrastructure bindings in entry points (e.g., main.ts).
// GOOD
export class MyService extends Effect.Service<MyService>()("MyService", {
dependencies: [DepA.Default, DepB.Default],
effect: Effect.gen(function* () { ... })
}) {}
// Also GOOD (Layer.provide chain)
const MainLayer = ServiceA.Default.pipe(
Layer.provideMerge(ServiceB.Default),
Layer.provide(Database)
)
// BAD (providing at call site)
const result = yield* myEffect.pipe(Effect.provide(someLayer))7. No try/catch
Everything uses Effect error channel. No try { } catch { } blocks in Effect code.
// GOOD
yield* Effect.tryPromise({
try: () => fetch(url),
catch: (e) => new FetchError({ cause: e })
})
// BAD
try {
const res = await fetch(url)
} catch (e) {
throw new Error("fetch failed")
}8. No Promise-Based Code
Service methods return Effect, not Promise. No async/await in Effect service implementations.
// GOOD
const fetchUser = Effect.fn("fetchUser")(function* (id: UserId) {
const user = yield* userRepo.findById(id)
return user
})
// BAD
const fetchUser = async (id: string) => {
const user = await userRepo.findById(id)
return user
}Error Patterns Checklist
1. Schema.TaggedError With HTTP Annotations
All errors reaching HTTP boundaries must use Schema.TaggedError with HttpApiSchema.annotations.
// GOOD
export class ResourceNotFound extends Schema.TaggedError<ResourceNotFound>()(
"@myorg/api/errors/ResourceNotFound",
{
type: Schema.optionalWith(ErrorType, { default: () => "invalid_request_error" as const }),
code: Schema.optionalWith(ErrorCode, { default: () => "resource_missing" as const }),
message: Schema.optionalWith(Schema.String, {
default: () => "The requested resource was not found.",
}),
param: Schema.String.pipe(Schema.optional),
resource_type: Schema.String.pipe(Schema.optional),
resource_id: Schema.String.pipe(Schema.optional),
},
HttpApiSchema.annotations({ status: 404, title: "Resource Not Found" })
) {}
// BAD
class NotFoundError extends Error {
constructor(message: string) { super(message) }
}2. Reverse Domain Notation for Tags
Error tags should use reverse domain notation matching the package structure.
// GOOD
"@myorg/api/errors/ResourceNotFound"
"@myorg/subscriptions/errors/CheckoutInitiation"
// BAD
"ResourceNotFound"
"NotFoundError"3. Static Factory Methods
Errors should have convenience constructors for common cases.
// GOOD
export class ResourceNotFound extends Schema.TaggedError<ResourceNotFound>()(...) {
static fromId(resourceType: string, id: string) {
return new ResourceNotFound({
message: `No such ${resourceType}: '${id}'`,
param: "id",
resource_type: resourceType,
resource_id: id,
})
}
}
export class BadRequestError extends Schema.TaggedError<BadRequestError>()(...) {
static invalidParam(param: string, message: string) {
return new BadRequestError({ code: "parameter_invalid", message, param })
}
static missingParam(param: string) {
return new BadRequestError({
code: "parameter_missing",
message: `Missing required parameter: ${param}`,
param,
})
}
}4. Rich Context Fields
Errors must include enough context for debugging. Never lose valuable information.
// GOOD - preserves context
new PaymentProcessingError({
message: `Failed to process payment for subscription ${subscriptionId}`,
subscriptionId,
provider,
cause: originalError,
})
// BAD - loses info
new PaymentProcessingError({ message: "Payment failed" })
new Error("something went wrong")5. catchTag/catchTags Only
Never use catchAll or mapError. Always handle specific error tags.
// GOOD
yield* effect.pipe(
Effect.catchTag("DatabaseError", (e) =>
Effect.fail(ResourceNotFound.fromId("paywall", String(id)))
),
Effect.catchTag("ValidationError", (e) =>
Effect.fail(BadRequestError.invalidParam("input", e.message))
)
)
// BAD
yield* effect.pipe(
Effect.catchAll((e) => Effect.fail(new InternalError({ message: String(e) })))
)
yield* effect.pipe(
Effect.mapError((e) => new InternalError({ message: "failed" }))
)6. Explicit Error Types
Error types should be specific to the domain, not generic. The error channel should tell you exactly what went wrong.
// GOOD
Effect<Paywall, PaywallNotFoundError | PaywallArchived | Unauthorized>
// BAD
Effect<Paywall, Error>
Effect<Paywall, unknown>7. Don't Catch HTTP-Annotated Errors
Let errors with HttpApiSchema.annotations propagate to the HTTP layer for automatic status code mapping. Don't re-wrap them.
// GOOD - let ResourceNotFound propagate
yield* paywallRepo.findById(id)
// BAD - catching and re-wrapping
yield* paywallRepo.findById(id).pipe(
Effect.catchTag("ResourceNotFound", () =>
Effect.fail(new GenericError({ message: "not found" }))
)
)8. Fire-and-Forget for Non-Critical Operations
For operations that shouldn't fail the request, use Effect.tapError + Effect.ignore:
// GOOD
yield* db.execute(...).pipe(
Effect.tapError((e) => Effect.logWarning("Failed to update", { error: e })),
Effect.ignore
)OTEL / Observability Patterns Checklist
1. Effect.fn With Trace Names
Effect.fn replaces the pattern where an arrow function wraps Effect.gen. It does NOT apply to bare Effect.gen calls without a wrapping function. It also does NOT apply in .test.ts files.
// BAD — arrow function wrapping Effect.gen, should use Effect.fn
const findById = (productId: ProductId) => Effect.gen(function* () {
// no trace name, anonymous span
})
// GOOD — Effect.fn replaces the arrow function wrapper
const findById = Effect.fn("ProductRepository.findById")(function* (
productId: ProductId,
organizationId: OrganizationId
) {
yield* Effect.annotateCurrentSpan("productId", productId)
// ...
})
// FINE — bare Effect.gen without wrapping function, no Effect.fn needed
yield* Effect.gen(function* () {
const user = yield* UserService
// inline composition, not a named function
})Naming Convention
Follow ServiceName.methodName format consistently:
UserService.findByIdPaywallRepository.createCampaignService.createTriggerOrgResolver.resolveProjectAccess
2. annotateCurrentSpan With Essential Data
Annotate spans with entity IDs and key business values. Don't over-annotate with internal state or step-by-step progress.
// GOOD - essential IDs and context
yield* Effect.annotateCurrentSpan("applicationId", applicationId)
yield* Effect.annotateCurrentSpan("paywallId", paywallId)
yield* Effect.annotateCurrentSpan("action", "create")
// BAD - over-annotating
yield* Effect.annotateCurrentSpan("step", "1")
yield* Effect.annotateCurrentSpan("loopIndex", i)
yield* Effect.annotateCurrentSpan("intermediateResult", JSON.stringify(result))
// BAD - sensitive data
yield* Effect.annotateCurrentSpan("apiKey", apiKey)
yield* Effect.annotateCurrentSpan("userEmail", email)What to Annotate
- Entity IDs (applicationId, paywallId, userId, etc.)
- Action being performed (create, update, delete)
- Key discriminators (platform, provider, scope)
- Counts (itemCount, resultCount) when relevant
What NOT to Annotate
- PII (emails, names, addresses)
- Secrets (API keys, tokens)
- Large payloads (full request/response bodies)
- Step-by-step progress counters
3. Structured Logging
Use Effect.log / Effect.logInfo / Effect.logWarning / Effect.logError with structured data. Never use console.log.
// GOOD
yield* Effect.logInfo("Processing payment").pipe(
Effect.annotateLogs({ orderId, amount, provider })
)
// BAD
console.log(`Processing payment for order ${orderId}`)
console.log("Payment result:", result)4. Error Spans
Errors should carry enough context for debugging without needing to look at other spans. Include entity IDs and operation context in error construction.
// GOOD
new ResourceNotFound({
message: `No such paywall: '${paywallId}'`,
resource_type: "paywall",
resource_id: String(paywallId),
})
// BAD
new ResourceNotFound({ message: "Not found" })5. Effect.withSpan for Non-fn Contexts
When Effect.fn isn't appropriate (inline pipelines, one-off compositions), use Effect.withSpan:
// GOOD
const result = yield* someEffect.pipe(
Effect.withSpan("OrgResolver.fromProject", {
attributes: { projectId, scope }
})
)Test Patterns Checklist
1. Use @effect/vitest
Always import from @effect/vitest, never plain vitest. It re-exports all standard vitest functions.
// GOOD
import { it, describe, expect } from "@effect/vitest"
// BAD
import { it, describe, expect } from "vitest"2. it.layer() for Test Setup
Use it.layer(TestLayer)((it) => { ... }) for providing test dependencies. Never use beforeAll/afterAll for Effect service setup.
// GOOD
const TestLayer = Layer.mergeAll(DatabaseTest, ServiceTest)
it.layer(TestLayer)((it) => {
describe("findById", () => {
it.scoped("returns project when found", () =>
Effect.gen(function* () {
const service = yield* MyService
// ...
})
)
})
})
// BAD
let service: MyService
beforeAll(async () => {
service = await setupService()
})
afterAll(async () => {
await teardown()
})3. it.scoped for Individual Tests
Inside an it.layer() block, use it.scoped for tests that need automatic resource cleanup.
// GOOD
it.scoped("creates and cleans up", () =>
Effect.gen(function* () {
const org = yield* createTestOrganization()
// org cleanup happens automatically via scope
})
)4. Effect.either for Error Testing
Use Effect.either + Either.isLeft() to test error cases. Never use try/catch or .catch().
// GOOD
it.scoped("fails for invalid id", () =>
Effect.gen(function* () {
const result = yield* service.findById(invalidId).pipe(Effect.either)
expect(Either.isLeft(result)).toBe(true)
if (Either.isLeft(result)) {
expect(result.left._tag).toBe("ResourceNotFound")
}
})
)
// BAD
it("fails for invalid id", async () => {
try {
await service.findById(invalidId)
fail("should have thrown")
} catch (e) {
expect(e).toBeInstanceOf(NotFoundError)
}
})5. Factory Functions for Test Data
Use factory functions from test/testFactories.ts or local helpers. Don't inline large object literals.
// GOOD
const org = yield* createTestOrganization()
const app = yield* createTestApplication(org.id)
const paywall = yield* createTestPaywall(app.id)
// BAD
const org = { id: 1, name: "test", createdAt: new Date(), ... }6. it.scoped.each for Parameterized Tests
Use .each for testing multiple cases with the same logic.
// GOOD
it.scoped.each([
{ platform: "ios", expected: "IOS" },
{ platform: "android", expected: "ANDROID" },
])("maps $platform correctly", ({ platform, expected }) =>
Effect.gen(function* () {
const result = yield* mapPlatform(platform)
expect(result).toBe(expected)
})
)7. Test Layer Composition
Use DefaultWithoutDependencies or .Default.pipe(Layer.provide(...)) for test layers.
// GOOD
const TestLayer = MyService.Default.pipe(
Layer.provide(MockRepository.Test),
Layer.provide(DatabaseTest)
)
// Also GOOD
const TestLayer = Layer.mergeAll(
MyService.Default,
MockRepository.Test
).pipe(Layer.provide(DatabaseTest))8. Coverage Assessment
When reviewing, check:
- New service methods have corresponding test cases
- Error paths are tested (not just happy path)
- Edge cases are covered (empty arrays, null values, boundary conditions)
- New error types are tested with
Effect.eitherpattern - Both success and failure scenarios for each public method
TypeScript Patterns Checklist
1. No as any
Never use as any to silence the compiler. It hides real type errors and defeats the purpose of TypeScript.
// GOOD — decode unknown data with Schema
const frame = Schema.decodeUnknownSync(BotGatewayServerFrame)(JSON.parse(payload))
// GOOD — use satisfies to validate shape instead of casting
Layer.provide(Layer.succeed(BotRpcClientConfigTag, {
backendUrl: BACKEND_URL,
botToken: BOT_TOKEN,
}))
// BAD — casting branded IDs to any in tests
const commandContext = {
commandName: "echo",
channelId: CHANNEL_ID as any,
userId: USER_ID as any,
orgId: ORG_ID as any,
}
// BAD — empty mock with zero type safety
Layer.provide(Layer.succeed(BotRpcClient, {} as any))If a third-party library returns any, wrap it immediately with a typed function or Schema.decodeUnknown rather than letting any leak into your code.
2. Prefer satisfies Over as
Use satisfies to validate a value matches a type without widening or lying. Use as only at truly opaque FFI boundaries where no better option exists.
// GOOD — satisfies on service implementations in Layer.effect
export const InMemoryGatewaySessionStoreLive = Layer.effect(
GatewaySessionStoreTag,
Effect.gen(function* () {
const offsetsRef = yield* Ref.make(new Map<BotId, string>())
return {
load: (botId) => Ref.get(offsetsRef).pipe(Effect.map((offsets) => offsets.get(botId) ?? null)),
save: (botId, offset) => Ref.update(offsetsRef, (offsets) => { ... }),
} satisfies GatewaySessionStore
}),
)
// GOOD — satisfies validates Schema shape at construction
sendFrame({
op: "HEARTBEAT",
sessionId: sessionId ?? undefined,
} satisfies Schema.Schema.Type<typeof BotGatewayHeartbeatFrame>)
// GOOD — satisfies on config objects
return {
electricUrl,
electricSourceId,
databaseUrl,
isDev,
port,
} satisfies ProxyConfig
// GOOD — as const satisfies for typed records
const providers = {
discord: discordAdapter,
slack: slackAdapter,
} as const satisfies Record<string, ChatSyncProviderAdapter>
// BAD — as hides mismatches, typos go undetected
return Effect.succeed({
getMessageActor: (messageId: string) => ...,
client,
botToken: config.botToken,
} as ActorsClientService)as const is fine — it narrows rather than widens.
3. Don't Manually Annotate Inferred Types
Effect's type system infers Layer compositions, service types, and Effect return types precisely. Manual annotations are redundant, drift-prone, and often wrong.
// GOOD — Layer.effect infers the Layer type from the tag
export const InMemoryBotStateStoreLive = Layer.effect(
BotStateStoreTag,
Effect.gen(function* () {
const stateRef = yield* Ref.make(new Map<BotId, Map<string, string>>())
return { ... } satisfies BotStateStore
}),
)
// GOOD — Effect.fn infers return type
const encrypt = Effect.fn("IntegrationEncryption.encrypt")(function* (token: string) {
const iv = crypto.getRandomValues(new Uint8Array(12))
const ciphertext = yield* Effect.tryPromise({ ... })
return {
ciphertext: Buffer.from(ciphertext).toString("base64"),
iv: Buffer.from(iv).toString("base64"),
keyVersion: currentKeyVersion,
} satisfies EncryptedToken
})
// BAD — manual Layer type annotation
const MainLayer: Layer.Layer<ServiceA | ServiceB, DatabaseError, Database> = ServiceA.Default.pipe(
Layer.provideMerge(ServiceB.Default),
Layer.provide(Database.Default)
)
This applies to:
- Layer compositions — never annotate
Layer.Layer<...>on composed layers - Service definitions — let
Effect.Service/Effect.Taginfer the shape - Stream types — never annotate
: Stream.Stream<...>when composing streams
Explicit : Effect.Effect<A, E, R> annotations are fine on plain arrow functions wrapping Effect.gen, on interface method signatures, and on public library API surfaces.