Effect for agentic development

A few months back, after watching Effect develop from the sidelines, we moved all our TypeScript work to Effect. Going forward, Effect will be our default for anything we build in TypeScript.

This post is not about building AI agents with @effect/ai. It is about building ordinary software with coding agents, and why Effect feels particularly well suited for that way of working.

The examples below use Effect v3 APIs.

So what changed?

The way we develop software has changed since the arrival of AI. It has made good developers much more productive while putting in roughly the same amount of time. This has also helped our studio pass some of those cost savings on to our clients.

Prompting is the easy part. I can't count the number of times I have had an agent work on a specific problem and then had to completely scrap the entire thing. Maybe the prompt was bad, maybe I had not given it enough context, or maybe it made one wrong decision early and confidently built everything else on top of it.

A lot of this can be fixed by giving the agent the right context and a good base to work from. Agents are much better at working inside an existing codebase than starting something from scratch.

Run this experiment yourself. Build a landing page for a product you have in mind. In one tab, make the initial decisions yourself: choose the tools, theme, fonts and maybe even the first version of the layout. Then ask the agent to continue from there. In another tab, try to one-shot the same landing page from a prompt.

Even though the first version gives the agent incomplete context, it will usually adapt to your taste and get closer to what you want. The existing code is evidence. It tells the agent more about your preferences than another paragraph in the prompt ever could.

Agents are good at continuation. They are much less reliable when asked to make every foundational decision at once.

The codebase is part of the prompt

When people talk about giving an agent more context, they usually mean a longer prompt, more documentation or access to more files. All of that helps, but the best context is encoded directly into the codebase.

A written instruction can be misunderstood. A type error gives concrete feedback. A test failure gives concrete feedback. A schema rejecting invalid data gives concrete feedback. Existing patterns show the agent how this particular codebase expects a problem to be solved.

The goal is not to write the perfect prompt. The goal is to build a repository where the correct path is obvious and the incorrect paths fail quickly.

Testing became more important, not less

I was already big on writing tests, especially for important code paths. Now that the cost of producing the first draft of a test is close to zero, it makes even less sense to skip them.

The cost of trusting a bad test is not zero. Tests still need to be reviewed, and an agent can write a test that simply confirms its own incorrect implementation. But this is still a much better position than having no executable definition of the behaviour at all.

Tests are executable context. They tell the agent what must continue to be true while it changes the code. Their value compounds as you add more features because they stop an agent from fixing one task by quietly breaking three existing ones.

In agentic development, tests are not only for catching human mistakes after the code is written. They are part of the feedback loop while the code is being written.

How Effect helps

Effect gives us strong semantic encoding in the type system. Effect is a large ecosystem, but its core type explains a lot of why it works so well for agentic development:

Effect.Effect<A, E, R>;

You can read this as a program that:

  • succeeds with a value of type A
  • can fail with an expected error of type E
  • requires services of type R before it can run

An Effect is also a description of work, not work that has already started running. The Effect runtime executes that description and can manage errors, interruption, concurrency, cleanup and observability in a consistent way.

Let's go over A, E and R one by one and see how each gives a coding agent better context.

A — the success value

A is the familiar part. It is the value your function produces when it succeeds: a User, a Response, a string, a number or maybe void.

You were likely already describing this part in normal TypeScript functions and promises. A Promise<User> tells us what we get when the operation succeeds.

The problem is that success is only one part of what we need to understand about a real program.

E — the expected error

Rust or Zig users will already be familiar with typed failures. There are also TypeScript libraries built around a similar idea, such as typescript-result.

All programs need a way to signal failure. In JavaScript, we typically do this with throw and try/catch:

const divide = (a: number, b: number): number => {
  if (b === 0) {
    throw new Error("Cannot divide by zero");
  }

  return a / b;
};

The problem is not that exceptions are always wrong. The problem is that the possible exception is hidden from the function signature. From (a: number, b: number) => number, neither a human nor an agent can tell whether the function can fail, what it can fail with or whether the caller is expected to do anything about it.

The same operation in Effect can make that failure explicit:

import { Data, Effect } from "effect";

class DivisionByZero extends Data.TaggedError("DivisionByZero")<{
  readonly dividend: number;
}> {}

const divide = (a: number, b: number): Effect.Effect<number, DivisionByZero> =>
  b === 0 ? Effect.fail(new DivisionByZero({ dividend: a })) : Effect.succeed(a / b);

The signature now tells us that the operation succeeds with a number and may fail with DivisionByZero. When this operation is composed with other effects, Effect carries their expected errors through the type system as a union.

This does not mean every caller must immediately recover from every error. Often the correct thing is to let a domain error move up to an HTTP handler, job boundary or application entry point and translate it there. The important part is that the failure is visible, can be handled precisely and is not silently hidden behind a successful return type.

There is another important distinction here: E represents expected failures. Bugs, invariant violations, unchecked exceptions and other unexpected failures are treated as defects rather than ordinary domain errors. Effect does not pretend that every possible thing that can go wrong belongs in E.

That distinction is useful for agents too. A UserNotFound error might be normal business logic. A TypeError caused by accessing a property on undefined is probably a bug and should not be casually swallowed by the same handler.

R — the requirements

R represents the services an Effect requires in order to run. This might be a database repository, an HTTP client, configuration, a clock, a file system, an email service or an LLM provider.

Take a getUserByEmail operation. In a normal codebase it may reach into an imported database singleton. You can discover that dependency by reading the implementation, but it is not represented in the function's type.

With Effect, the dependency can be made explicit:

import { Context, Data, Effect, Layer } from "effect";

type User = {
  readonly id: string;
  readonly email: string;
};

class UserNotFound extends Data.TaggedError("UserNotFound")<{
  readonly email: string;
}> {}

class UserRepository extends Context.Tag("UserRepository")<
  UserRepository,
  {
    readonly findByEmail: (email: string) => Effect.Effect<User, UserNotFound>;
  }
>() {}

const getUserByEmail = Effect.fn("getUserByEmail")(function* (email: string) {
  const users = yield* UserRepository;
  return yield* users.findByEmail(email);
});

// Effect<User, UserNotFound, UserRepository>

From the resulting type, the agent can see all three parts of the operation:

  • it succeeds with a User
  • it can fail with UserNotFound
  • it needs a UserRepository

The function does not need to know whether that repository uses Postgres, D1, an API or an in-memory map. We provide the implementation separately using a Layer.

That makes testing straightforward:

const UserRepositoryTest = Layer.succeed(UserRepository, {
  findByEmail: (email) =>
    email === "test@example.com"
      ? Effect.succeed({ id: "user_1", email })
      : Effect.fail(new UserNotFound({ email })),
});

const testProgram = getUserByEmail("test@example.com").pipe(Effect.provide(UserRepositoryTest));

// Effect<User, UserNotFound, never>

Once the test layer is provided, the UserRepository requirement disappears from the type. The same business logic can run against a real repository in production and a small deterministic implementation in tests.

This is what it means to mock the database through R. We are not patching a global import and hoping every call goes through it. We are providing a different implementation of a dependency that the program already declared.

Runtime boundaries need structure too

TypeScript types disappear at runtime. An API response does not become safe just because we wrote const user: User, and a coding agent can make almost any type error disappear with an unchecked cast.

Effect's Schema module lets us define and validate the boundary between unknown external data and trusted application data:

import { Schema } from "effect";

const User = Schema.Struct({
  id: Schema.String,
  email: Schema.String,
});

const decodeUser = Schema.decodeUnknown(User);

Now data from an API, environment variable, queue, database or model output must be decoded before the rest of the application treats it as a User.

This gives the agent another useful constraint. It cannot safely assume that unknown JSON matches an internal type just because the happy-path example did.

Why Effect works well with coding agents

The architecture is visible in the signatures

A well-designed Effect signature is a compact description of a feature. It shows what comes back, what can go wrong and what the operation needs from the rest of the system.

The agent does not have to infer all of this from a chain of imports, hidden throws and global state. When effects are composed, the types continue carrying that information through the program.

The type signature is not a substitute for documentation, but it is documentation that the compiler keeps up to date.

The compiler becomes part of the feedback loop

The compiler cannot prove that the business logic is correct, but it can catch a surprising amount of structural drift. If an agent introduces a new service requirement, changes an error type or returns the wrong success value, that change propagates through the type system.

This gives the agent a fast loop:

  1. make a small change
  2. run the type checker
  3. inspect the exact failures
  4. fix the affected boundaries
  5. run the tests

Effect's LSP tooling also improves diagnostics and can catch non-idiomatic Effect code. This matters because a useful coding agent needs more than permission to edit files. It needs fast, precise feedback after every edit.

Tests can replace the outside world

Because services are explicit, tests can provide small implementations for the database, clock, randomness, configuration, HTTP clients and other external systems.

That lets an agent run meaningful tests without production credentials, live APIs or long delays. Effect even provides test services such as TestClock, so time-based logic can be tested without actually waiting for time to pass.

Fast and deterministic tests make the agent more useful because it can validate its own work repeatedly instead of stopping after the code merely compiles.

Runtime information is structured

Effect has built-in support for logging, metrics and tracing. Important workflows can be named with Effect.fn or wrapped in spans, and their logs can carry structured annotations.

This does not automatically give an agent access to production. But when we give an agent the relevant logs, traces or failing Exit values, it receives much better evidence than a vague report saying that something broke.

Observability is no longer only something humans inspect after deployment. It can become runtime context for the next debugging pass performed by an agent.

Common operational problems use one vocabulary

Real applications need retries, timeouts, cancellation, concurrency, resource cleanup and graceful interruption. Without a shared model, agents tend to introduce a new helper or slightly different pattern every time one of these problems appears.

Effect already has composable primitives for these concerns. A database connection, file handle or other scoped resource can have its cleanup tied to its lifetime. Concurrent work runs in fibers with structured lifetimes. Retry and timeout policies can be expressed without hiding them inside custom promise wrappers.

The benefit is not simply that Effect has a lot of utilities. It is that the codebase gets one consistent vocabulary for describing operational behaviour. Once that vocabulary is established, the agent has fewer patterns to guess from and fewer new abstractions to invent.

Effect is not enough by itself

Effect does not automatically turn an unstructured codebase into a good one. It is still possible to create vague errors, giant services, meaningless layers and impressive-looking types around poor business logic.

The value comes from agreeing on a few patterns and applying them consistently. For us, a useful baseline for agent-written Effect code looks like this:

  • expected domain failures use specific tagged errors
  • external I/O sits behind services
  • unknown data is decoded at runtime boundaries
  • production services and test services are provided through layers
  • important workflows have tests and useful spans
  • the agent runs formatting, linting, type checking and tests after each small change
  • unchecked any, broad casts, Effect.orDie and catch-all recovery are not used merely to silence the compiler

That final point matters. An agent will discover escape hatches very quickly. The goal is not to make the red underline disappear. The goal is to preserve the information that made the red underline useful in the first place.

Effect is also a fast-moving ecosystem, which means coding agents can generate examples using old APIs or mix patterns from different versions. Pinning the version is important. Giving the agent current, local reference material is even better.

The Effect team recommends keeping the feedback loop tight, using the Effect LSP and giving agents access to real Effect source code rather than making them guess from isolated snippets. One practical setup is to vendor the Effect repository under something like repos/effect, treat it as read-only reference material and document that rule in AGENTS.md.

A small project instruction can be more useful than a huge generic prompt:

## Effect

- Use the patterns already established under `src/`.
- Treat `repos/effect` as read-only reference material.
- Inspect the current Effect source and tests before guessing an API.
- Do not use `any`, unchecked casts or `Effect.orDie` to hide errors.
- Run type checking and tests after every behaviour change.

Again, the important thing is not the exact file or wording. It is that the agent has a current source of truth and a quick way to find out when it is wrong.

Conclusion

Effect does not make coding agents smarter. It makes the codebase harder to misunderstand.

With human-written code, ambiguity is expensive. With agent-written code produced at much higher volume, that ambiguity compounds quickly. Hidden errors, implicit dependencies, unvalidated inputs and inconsistent operational patterns all create places where an agent can make a locally reasonable decision that is globally wrong.

Effect gives us a strong base: explicit expected errors, explicit requirements, testable services, runtime schemas, structured observability and one model for composing asynchronous and concurrent work.

A prompt is temporary context. Types, tests, services, schemas and traces are durable context that lives with the codebase.

That is why Effect feels like such a good fit for agentic development, and why it will be the default for our TypeScript work going forward.

Further reading

More articles

Cloudflare Pricing Calculator

Estimate monthly Cloudflare costs across Workers, Durable Objects, D1, R2, KV, Queues, Workflows, AI, media, and more.

Read more

Effect for agentic development

Why Effect's explicit errors, requirements, schemas, services, and feedback loops make TypeScript codebases easier for coding agents to understand.

Read more

Bring us the problem worth solving

Our office

  • Bangalore
    GoodWorks Infinity Park
    Electronics City