Reading T3 Code

Chapter 6 · Architecture

Effect: services, layers, and typed errors

How T3 Code's server uses Effect to describe work, declare dependencies as services, build them with layers, and put every failure in the type signature, with a fair look at when that is worth it.

13 min read

Almost every file under apps/server in T3 Code imports from effect. If you open one cold, it looks unfamiliar. You see yield* where you expect await, classes that hold no data, and return types with three type parameters. This chapter teaches the smallest mental model you need to read that code, using T3 Code’s own files rather than the library’s tutorials.

You have shipped projects where errors are thrown, dependencies are imported, and tests patch globals. That works, and this chapter does not pretend otherwise. The goal is to show what T3 Code gets from doing it differently, where you can see the same ideas already forming in your own code, and when the cost of learning Effect pays off for one person.

An Effect is a value that describes work

TL;DR

An Effect is a plan for some work, not the work itself. Its type says what it returns, how it can fail, and what it needs from outside. Nothing runs until the program’s entry point runs it.

A Promise starts running the moment you create it. An Effect does not. It is a value that describes a program, and something at the edge of the app has to run it. That one difference matters for the rest of this chapter. Because the work has not started yet, you can still decide what it depends on, how it is traced, and what happens when it fails.

The type has three slots, Effect.Effect<A, E, R>:

  • A is the success value, like the T in Promise<T>.
  • E is the set of errors this work can fail with.
  • R is the set of services it needs before it can run.

Here is a real interface from the server. WorkspacePaths turns a folder the user typed into a checked absolute path, and keeps file paths inside a project root.

t3code/apps/server/src/workspace/WorkspacePaths.ts:83-104
export class WorkspacePaths extends Context.Service<
  WorkspacePaths,
  {
    readonly normalizeWorkspaceRoot: (
      workspaceRoot: string,
      options?: { readonly createIfMissing?: boolean },
    ) => Effect.Effect<
      string,
      | WorkspaceRootNotExistsError
      | WorkspaceRootCreateFailedError
      | WorkspaceRootStatFailedError
      | WorkspaceRootNotDirectoryError
    >;
    readonly resolveRelativePathWithinRoot: (input: {
      workspaceRoot: string;
      relativePath: string;
    }) => Effect.Effect<
      { absolutePath: string; relativePath: string },
      WorkspacePathOutsideRootError
    >;
  }
>()("t3/workspace/WorkspacePaths") {}

Read normalizeWorkspaceRoot from left to right. It takes a string. It returns an Effect that succeeds with a string and can fail in exactly four named ways. The R slot is left out, which means “needs nothing”. You learn all of that from the signature, without opening the body.

Inside a body, Effect code uses generator functions. Effect.gen(function* () { ... }) plays the role of an async function, and yield* plays the role of await. When you yield* an Effect, you get its success value, and any failure jumps out of the function the way a thrown error would. The difference is that the compiler tracks that failure in E.

T3 Code pins the library version in its workspace catalog (pnpm-workspace.yaml sets effect: 4.1.2). The repo’s AGENTS.md tells contributors to read the internal Effect services page before adding server code, and that page is the source for most of the rules below.

Services are interfaces and layers build them

TL;DR

A service is a named interface. A layer is the recipe that builds it, and the recipe says what other services it needs. The app wires every layer together once at the entry point, so no module reaches for a global.

The class in the last section, WorkspacePaths, holds no logic. Context.Service gives the interface a name, the string "t3/workspace/WorkspacePaths", so other code can ask for it. The implementation lives lower in the same file:

t3code/apps/server/src/workspace/WorkspacePaths.ts:106-213
const make = Effect.gen(function* () {
  const fileSystem = yield* FileSystem.FileSystem;
  const path = yield* Path.Path;
  // ...
  return WorkspacePaths.of({ normalizeWorkspaceRoot, resolveRelativePathWithinRoot });
});

export const layer = Layer.effect(WorkspacePaths, make);

make asks for two other services, FileSystem and Path, by yielding their tags. It does not import Node’s fs. It does not take them as function parameters either. Because make yields them, its type records that it needs them, and so does the type of layer. A layer is a value that knows how to build one service from the services it needs. Layer.effect(WorkspacePaths, make) reads as “to get a WorkspacePaths, run make”.

The internal guide says dependencies “come from the environment (yield* FileSystem.FileSystem), never as parameters to make, so the types of make and layer show what they need. Never hide one in a module global, a closure over a singleton” (effect-services.md).

Even process.platform is a service

The team applies this rule further than you might expect. Reading the operating system name looks harmless, but it is a hidden global, and it makes tests depend on the machine they run on. So the shared package wraps host facts in references with defaults:

t3code/packages/shared/src/HostProcess.ts:8-10
export const Platform = Context.Reference<NodeJS.Platform>("@t3tools/shared/HostProcess/Platform", {
  defaultValue: () => process.platform,
});

A Context.Reference is a service with a default. Production code gets the real platform without any wiring. A test can provide "win32" on a Linux laptop. WorkspacePaths uses the same idea for the home folder when it expands ~ (yield* HostProcess.HomeDirectory on line 137).

A rule like this only holds if something checks it. T3 Code has a custom lint rule, no-global-process-runtime, whose error message tells you the fix:

t3code/oxlint-plugin-t3code/rules/no-global-process-runtime.ts:21-22
const message = (property: string) =>
  `Use HostProcess.${property === "arch" ? "Architecture" : "Platform"} instead of process.${property}; inject the runtime reference in Effect code and provide it explicitly in tests.`;

Julius Marminge added the references and the rule together in PR #2959. Files that run before any Effect runtime exists, such as build scripts and the standalone service launcher, turn the rule off with a reason, for example “Standalone launcher has no Effect runtime” in serviceLauncher.ts.

Running happens once, at the edge

If every module only describes work, something has to run it. The CLI entry point does that in three lines:

t3code/apps/server/src/binCli.ts:141-144
    Effect.scoped,
    Effect.provide(layerCliRuntime),
    NodeRuntime.runMain,
  );

Effect.provide hands the program the layers it asked for, and NodeRuntime.runMain finally runs it. The guide limits runPromise and similar calls to “application and framework boundaries: React, native callbacks, the CLI, HTTP adapters. Never in a domain service” (effect-services.md). If the program needs a service that no provided layer builds, TypeScript reports it here, at compile time, instead of a Cannot read properties of undefined in production.

The transports follow the same shape. A WebSocket handler in ws.ts decodes input, calls one service method, and maps the error:

t3code/apps/server/src/ws.ts:2827-2834
        [WS_METHODS.projectsCreateNew]: (input) =>
          managedFolders
            .createNamedProject(input)
            .pipe(
              Effect.mapError(
                (cause) => new OrchestrationDispatchCommandError({ message: cause.message, cause }),
              ),
            ),

The guide gives the reason for keeping handlers this thin. Users reach a feature over the WebSocket, agents reach it through MCP tools, and scheduled tasks and the CLI run it too, so “logic written into one handler is missing from the others, and testing it needs a socket” (effect-services.md).

Errors are part of the signature

TL;DR

Each way something can fail is its own small class with a tag, and the function’s type lists those classes. Callers handle failures by tag, and the compiler tells them which tags are still possible.

Each failure in WorkspacePaths is a class built with Schema.TaggedError:

t3code/apps/server/src/workspace/WorkspacePaths.ts:11-21
export class WorkspaceRootNotExistsError extends Schema.TaggedError<WorkspaceRootNotExistsError>()(
  "WorkspaceRootNotExistsError",
  {
    workspaceRoot: Schema.String,
    normalizedWorkspaceRoot: Schema.String,
  },
) {
  override get message(): string {
    return `Workspace root does not exist: ${this.normalizedWorkspaceRoot}`;
  }
}

The string "WorkspaceRootNotExistsError" becomes a _tag field on every instance. The fields are structured data that a log or a UI can read without parsing a sentence. The message is built from those fields. The guide forbids building it from the wrapped cause, because a raw cause can carry payloads, credentials, or command output (effect-services.md).

To fail, the body yields the error, as in return yield* new WorkspaceRootNotExistsError({ ... }) on lines 161-166. That line does what throw does in your code, except the compiler adds the class to E. If you add a fifth failure to normalizeWorkspaceRoot and forget to list it in the interface, the file stops compiling.

Handling an error looks like this, from the code that picks a friendly name for the machine the server runs on:

t3code/apps/server/src/environment/ServerEnvironmentLabel.ts:95-105
    Effect.catchTags({
      ServerEnvironmentLabelFileError: (error) =>
        Effect.logDebug(error.message).pipe(
          Effect.annotateLogs({
            operation: error.operation,
            path: error.path,
            cause: error,
          }),
          Effect.as(null),
        ),
    }),

catchTags takes an object keyed by tag. Inside the handler, error is typed as that exact class, so error.operation autocompletes. After this step, ServerEnvironmentLabelFileError disappears from the E of the result, because it has been handled. A failure to read /etc/machine-info is logged at debug level and becomes null, which tells the caller to try the next way of finding a name. The typed error is a value the code can branch on, not a message it has to read.

There is a lint rule for this too. prefer-catch-tags exists in oxlint-plugin-t3code/rules, matching the guide’s line “Catch known tags with Effect.catchTags({ ... }), even for one tag” (effect-services.md).

Tests swap layers instead of patching globals

TL;DR

Because every dependency arrives through the environment, a test can hand the code a fake version of exactly one thing. Tests also run on a fake clock, so a five-second timeout takes no real time.

Here is a test with a hard problem. On Linux, the server asks hostnamectl for the machine’s name, and hostnamectl can hang forever when a system daemon is down. The code sets a five-second timeout. How do you test a hang without waiting five seconds, and without a broken Linux box?

t3code/apps/server/src/environment/ServerEnvironmentLabel.test.ts:33-52
it.effect("falls back to the hostname when hostnamectl never answers", () =>
  Effect.gen(function* () {
    // Without /etc/machine-info the label comes from hostnamectl, which waits on D-Bus.
    const label = yield* resolveServerEnvironmentLabel({ cwdBaseName: "project" }).pipe(
      Effect.provideService(HostProcess.Platform, "linux"),
      Effect.provideService(HostProcess.Hostname, "devbox"),
      Effect.provide(
        Layer.mergeAll(
          FileSystem.layerNoop({ exists: () => Effect.succeed(false) }),
          ProcessRunner.layer.pipe(
            Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, neverExits)),
          ),
        ),
      ),
      Effect.forkChild,
    );
    yield* TestClock.adjust("5 seconds");
    assert.equal(yield* Fiber.join(label), "devbox");
  }),
);

Read the pipe from top to bottom:

  1. provideService(HostProcess.Platform, "linux") makes the code read "linux" as the platform, on any machine.
  2. FileSystem.layerNoop is a file system where /etc/machine-info does not exist.
  3. The real ProcessRunner.layer runs, but on top of a fake process spawner. neverExits, defined at the top of the file, returns a child process whose exit never arrives.
  4. TestClock.adjust("5 seconds") moves a fake clock forward. it.effect runs every test on that clock, so the timeout fires instantly.

Nothing is patched. No global process object is overwritten and restored. The test replaces two outside dependencies, the file system and the spawner, and keeps the real ProcessRunner code under test. That matches the guide’s rule that tests use “test layers only for external dependencies. Don’t mock the logic under test” (effect-services.md).

A simpler test file shows the common setup. WorkspacePaths.test.ts builds one layer from WorkspacePaths.layer and the real Node services, then passes it to it.layer(layerTest). Every test in that block can yield* WorkspacePaths.WorkspacePaths and get the real implementation running against a temporary folder.

The lint rule no-manual-effect-runtime-in-tests keeps tests in this shape. It reports any Effect.runPromise or ManagedRuntime.make inside a test file:

t3code/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts:88-93
        if (occurrenceCount <= allowedCount) return;

        context.report({
          node: node.callee,
          message: `Do not use ${runner.value} in tests. Use @effect/vitest with it.effect(...) and test layers instead.`,
        });

The allowedCount check is worth a second look. The rule accepts a maxOccurrences option that its schema describes as a “Legacy debt ceiling for this file”. Old files can keep their existing calls, and any new one fails. This is one way a team adopts a stricter rule without rewriting everything in one pull request.

How your projects handle the same problems

TL;DR

fud already maps errors to responses in one place, and antre already passes context into handlers. The gap is that both lose the kind of error along the way. Effect fixes that, but it costs a lot to learn, so borrow the habits first.

fud catches errors at one boundary

fud’s Cloudflare Worker already follows the shape T3 Code’s guide asks for. Route code throws, and one catch at the edge turns errors into HTTP responses:

fud/worker/index.ts:233-254
    try {
      return Response.json(await route(request, env), { headers });
    } catch (error) {
      if (error instanceof HttpError)
        return Response.json({ error: error.message }, { status: error.status, headers });
      if (error instanceof GoogleError)
        return Response.json(
          {
            error:
              error.code === "reconnect"
                ? "Reconnect Gmail to continue importing."
                : "Gmail could not be reached. Try again.",
          },
          { status: 502, headers },
        );
      // Do not put provider errors, OAuth URLs, email content or credentials in logs/responses.
      console.error("fud_request_failed");
      return Response.json(
        { error: "Your request could not be completed. Try again." },
        { status: 503, headers },
      );
    }

Errors are classes, the mapping lives in one place, and the comment keeps secrets out of logs, the same rule T3 Code writes about safe values. GoogleError even carries a typed code union.

What TypeScript cannot tell you is which of these a given route can throw. route() returns Promise<...>, and the catch receives unknown. If you add a fifth GoogleError code, nothing points you to this catch. The instanceof chain works because you remember every class, and Effect’s E slot exists so you do not have to remember.

Dependency wiring in fud is explicit already. route() receives env, and it builds new Ledger(env.DB, owner.userId) on line 85. That is plain constructor injection, and it is why fud’s ledger tests can run the real code against a Miniflare D1 database. Where fud reaches for globals is in tests, which use vi.stubGlobal for fetch, indexedDB, and navigator (for example tests/push-control.test.ts). That is the exact pattern T3 Code moved away from when it put process.platform behind a reference.

antre passes context but flattens errors to strings

antre’s Convex functions get dependencies through ctx, and antre extends that object itself. workspaceQuery checks access and then calls the handler with { ...ctx, workspaceId, via: "user" }. That is close to what a layer does. A wrapper builds the context a handler needs, and the handler’s type says what it receives.

Errors are where antre loses information. Access checks throw plain strings, throw new Error("Unauthorized") and throw new Error("Not found") in convex/access.ts. By the time the SvelteKit side catches them, the reason survives only as text, and antre recovers it with a regular expression:

antre/src/lib/server/owner.ts:15-18
export function reason(cause: unknown, fallback: string) {
  const message = cause instanceof Error ? cause.message : "";
  return /Uncaught Error: ([^\n]+)/.exec(message)?.[1] ?? fallback;
}

Callers such as workspaceActions.ts then return status 400 for every failure, because a string cannot say whether the user was signed out or the post was missing. A tagged error with a code field would let the same code answer 401 or 404.

When Effect is worth it for one person

Be honest about the cost. Effect has its own way of writing functions, its own standard library for files, processes, and SQL, and type errors that run to dozens of lines when a layer is missing. T3 Code pays that cost because its server talks to many agent processes, the file system, Git, SQLite, and several networks at once, and because many people and agents edit it. Their guide and lint rules show how much effort it takes to keep that code consistent.

For fud and antre, adopting Effect would be overkill today. Each has one backend and a few external services, and the existing tests already run real code. The habits are cheap to borrow without the library:

  • Make each failure a class or a tagged object with a code, never only a string.
  • Map errors to responses in one place at the edge, which fud already does.
  • Pass dependencies in, and keep process, fetch, and Date.now() out of the logic you want to test.

Reach for Effect when you notice you are writing the same retry, timeout, and cleanup code around several services, or when an instanceof chain has grown long enough that you no longer trust it.

Try it

TL;DR

Give antre’s errors a code so the server can tell “signed out” from “missing”. Make fud’s Gmail error mapping exhaustive so the compiler finds new codes for you.

  1. Stop parsing error messages in antre. In convex/access.ts, replace throw new Error("Unauthorized") and throw new Error("Not found") with Convex’s ConvexError from convex/values, carrying data such as { code: "unauthorized" } and { code: "not_found" }. In src/lib/server/owner.ts, add a function that reads cause.data.code when cause is a ConvexError and returns a status (401 or 404). Use it in one action in workspaceActions.ts to pick the status passed to fail. It works when a unit test of your new function returns 401 for a ConvexError with code "unauthorized" and 404 for "not_found", the existing tests still pass with npm test, and that action no longer calls reason().
  2. Make fud’s Gmail mapping exhaustive. In worker/index.ts, move the GoogleError branch into a function googleErrorMessage(code: GoogleError["code"]) with a switch over all four codes and a default branch that assigns code to a variable of type never. Run pnpm typecheck. Then add a fifth code, such as "quota", to the union in worker/google.ts. It worked if typecheck now fails inside your switch and points you to the place you need to update.

Text size

Theme