At Fiberplane we were early adopters of Claude Code, and like most teams we went through a learning curve. The first sessions were promising but messy: the agent would produce code that worked but didn’t fit the codebase, handle errors inconsistently, or quietly introduce patterns we didn’t want. We spent review time correcting things that felt like they should have been obvious from context.
The quality of what the agent produces is largely a function of how explicit the codebase is. When the code itself carries information: for example, where errors come from, what a function depends on, which patterns are allowed, the agent follows those patterns and can self-correct when it drifts. Putting this information in a CLAUDE.md helps, but in a long enough session, agents start to drift from written instructions. The information needs to be in the code itself, enforced by tooling, so the agent gets corrected just-in-time rather than gradually ignoring the rules. This post shares the changes we made and what we learned along the way.
Making ts more explicit with Effect
Standard TypeScript has a particular kind of implicitness hidden inside implementations. Agents work with function signatures. They read types to understand what something does and what can go wrong. When that information isn’t in the type, the agent has to infer it from the implementation. Effect makes TypeScript code more explicit when it comes to control flow, dependencies and errors.
Explicit control flow
Normal TypeScript uses throw and try/catch, which creates a parallel, non-sequential control flow. Functions can suddenly jump to a completely different place when an error is thrown. Effect forces all errors to be represented as results in the return type, so the agent (and humans) can always read code top-to-bottom and understand every possible path.
Effect’s Data.TaggedError makes this concrete: errors have identity, a name, and typed properties:
export class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{
readonly userId: string;
}> {
get message() {
return `User ${this.userId} not found`;
}
}
The agent can handle it specifically with Effect.catchTag, instead of catching everything and hoping for the best:
yield *
fetchUser(id).pipe(
Effect.catchTag("UserNotFoundError", (err) =>
Effect.logWarning("User not found, returning default", {
userId: err.userId,
}).pipe(Effect.map(() => defaultUser)),
),
);
When a test fails or a runtime error surfaces, the agent has enough information to diagnose and fix it. It can point at the error type, find where it’s defined, understand what went wrong, and recover or report correctly.
Explicit Signatures
Every Effect<A, E, R> carries three pieces of information in its type: the success value, the typed error, and the services it requires.
// typescript: only the happy path is visible
async function fetchUser(id: string): Promise<User> { ... }
// Effect: success, failure, and dependencies are all visible
const fetchUser = (id: string): Effect.Effect<User, UserNotFoundError | NetworkError, DatabaseService> => ...
The TypeScript version’s signature tells that it returns a User if everything goes well. The possible failure modes and what the function depends on are hidden in the implementation. The Effect version surfaces all of it. An agent reading this type knows what can go wrong and what dependencies are needed without reading implementation details.
This matters beyond agents too. When you’re reviewing a 2000+ line PR, you’re not reading line by line. You’re trying to understand the moving parts. Explicit signatures make that possible without diving into every implementation detail.
The Reference Trick
Effect’s API and docs are large. One thing worth doing: clone the Effect source into a references/ folder so the agent can read the actual API and source directly, rather than relying on what it was trained on. For us the folder is gitignored and excluded from linting.
Enforcing Patterns with ast-grep
Here’s the thing about CLAUDE.md and documented conventions: in a long enough session, agents start to ignore them. They start optimizing locally rather than following the global patterns. You’ll get a try/catch block, or a console.log, or a new Error() that compiles fine but breaks the philosophy you’ve set up with Effect earlier.
The only robust solution is a just-in-time gate. When the agent tries to commit or finishes a file, something checks the code against the patterns and fails loudly if they’re violated.
We use ast-grep as a structural syntax scanner that operates on the syntax tree of code (using tree-sitter under the hood). You define patterns in a YAML file, and it matches those patterns against the codebase. Critically, it’s run as part of the CI pipeline and configured to exit with an error when a banned pattern is found. And they have to be errors, not warnings. We learned this the hard way. Warnings get completely ignored. An error blocks progress. The agent reads the message, understands what it should have done, and fixes it. Ast-grep lets the codebase enforce taste and architectural decisions automatically, so the human doesn’t have to constantly correct the agent mid-session.
We have rules for the most common Effect anti-patterns:
| Rule | What it catches |
|---|---|
no-try-catch | try/catch in Effect code — use Effect.try or Effect.catchTag |
no-bare-new-error | new Error(...) — use Data.TaggedError |
no-console-log | console.* — use Effect.log, which integrates with tracing |
no-silent-catch | Effect.catchAll without logging — always log before recovering |
no-runpromise-in-effect | Effect.runPromise inside Effect code — Effect.runPromise only belongs at entry points |
no-throw-in-effect | throw inside Effect.gen — use Effect.fail |
no-drift-fs | Direct node:fs imports — use Effect’s FileSystem service |
tagged-error-location | Data.TaggedError outside errors.ts — keep errors co-located |
The no-silent-catch rule came from a repeating pattern we saw: the agent would catch an error, not crash the app, but silently swallow it. That’s a failure state that becomes invisible. With structured logging enforced at the catch site, you can point the agent at the log output and ask it to find what went wrong.
The tagged-error-location rule is about navigability. When all errors live in errors.ts, there’s one place to look for everything that can go wrong in a module. The agent always knows where to define them and where to find them.
If you find a pattern you don’t like, have the agent write an ast-grep rule to ban it immediately. That’s the workflow: you see a bad pattern, ast-grep prevents it from coming back.
The key is writing the error message as an instruction, not a description. The message field is what the agent reads when it hits the violation. Pair it with a note block that shows exactly what to do instead:
message: "Avoid try-catch in Effect code - use Effect.try or Effect.catchTag instead"
note: |
Effect code should avoid try-catch blocks. Use Effect's error handling:
- Effect.try() for wrapping code that might throw
- Effect.catchTag() for handling tagged errors
// ❌ Bad
try { const result = riskyOperation(); } catch (e) { ... }
// ✅ Good
const result = yield* Effect.try({
try: () => riskyOperation(),
catch: (e) => new OperationError({ cause: e })
});
When the rule fires, the terminal output looks like this:
error[no-try-catch-in-effect]: Avoid try-catch in Effect code - use Effect.try or Effect.catchTag instead
--> apps/api/src/users/service.ts:42:5
|
42 | try {
| ^^^
= note: Effect code should avoid try-catch blocks. Use Effect's error handling:
- Effect.try() for wrapping code that might throw
- Effect.catchTag() for handling tagged errors
...
The agent reads the violation, reads the note, and fixes it without you having to intervene. The error message is doing the work a code reviewer would otherwise do.