ANTIGRAVITY LABJP
Articles/App Development
App Development/2026-04-29Intermediate

Building Robust Error Handling with Antigravity and Effect-TS

A practical guide to writing Effect.gen and Layer code in Antigravity. Learn how to lift errors into the type system with real-world patterns I used in production.

antigravity436effect-tstypescript27error-handling2

Every time you wrap another block in try/catch, you lose a little more confidence in what your code can actually throw. If you have shipped TypeScript on Node.js, you have probably felt that creeping uncertainty—where one helper deep in the stack quietly raises something nobody documented. Effect-TS (@effect/io) is a library that hands that anxiety over to the type system, lifting both the success type and every failure mode into the function signature.

The catch is that Effect-TS leans heavily on generator syntax and advanced inference, which makes it painful in editors that cannot follow the type parameters. You end up squinting at red squiggles instead of writing logic. Antigravity's AI completion picks up the R, E, and A parameters from context, and the experience changes noticeably once you pair the two—the model has enough type information to suggest the next yield* instead of guessing at it.

This article distills the patterns that earned their keep when I rewrote a backend API with Effect-TS, framed around the Antigravity workflow I now reach for first. Everything below is what I wished I had known when I started.

Why Antigravity and Effect-TS click

The core type is Effect<Requirements, Error, Success>. Unlike async/await, the error channel lives in the type itself, so a function's signature tells you exactly what can go wrong without reading the body. You also see the requirements—the services this computation needs in order to run—as the first parameter, which means dependency injection becomes a type-checked exercise rather than a runtime hope.

To collect that benefit while writing the code, you need the discipline of yield* _(...)-ing every effectful step. Antigravity's completion infers the three parameters and surfaces the errors you should still account for, so you can keep the types tidy as you go. When I drift and forget to handle a tag, the AI tends to remind me by suggesting a catchTag block immediately after.

I also learned to keep node_modules/@effect/io/dist/esm/index.d.ts open in a side tab. Pulling that declaration file into context dramatically sharpens the AI's suggestions, especially around lesser-used combinators like Effect.acquireRelease or Effect.scoped. That small habit—keeping the type definitions in the model's window—is the first trick I would teach a teammate starting Effect-TS in Antigravity.

Combine it with the practices in the strict TypeScript guide for Antigravity and the completion gets even tighter. The two reinforce each other: strict mode forces you to confront missing cases, and Effect-TS gives you the language to handle them.

Your first Effect.gen block

Here is the smallest example I reach for: an HTTP call followed by parsing. Ask Antigravity to "rewrite this with Effect.gen" and you will get something like:

import { Effect } from "effect"
import { HttpClient } from "@effect/platform"
 
// fetchUserProfile: take a user ID, call the API, and parse the result
// Return type: Effect<HttpClient, FetchError | ParseError, UserProfile>
const fetchUserProfile = (userId: string) =>
  Effect.gen(function* (_) {
    const client = yield* _(HttpClient.HttpClient)
    const response = yield* _(
      client.get(`/api/users/${userId}`)
    )
    const json = yield* _(response.json)
    return parseUserProfile(json) // validated by Schema
  })
 
// Expected behavior once a Layer is provided:
// success → UserProfile object
// failure → FetchError or ParseError surfaces in the E channel

The crucial detail: nothing inside Effect.gen ever throws. Each yield* _(...) returns the success value, while errors accumulate in the E parameter. Callers cannot compile until they provide the HttpClient via a Layer, which means dependencies are visible in types as well. There is no "I forgot to mock that" moment in tests, because the compiler is the one keeping score.

A subtle benefit shows up when you read this code six months later. The signature Effect<HttpClient, FetchError | ParseError, UserProfile> describes the function more honestly than any JSDoc comment ever did, and you do not have to maintain it separately—it stays correct because the compiler refuses to let it drift.

Model domain errors as discriminated unions

A common Effect-TS misstep is to lump every failure into a single Error subclass. That throws away half of the type system's value, since you can no longer narrow on what went wrong. I recommend Data.TaggedError and a domain-specific union per use case.

import { Data } from "effect"
 
// Each tag becomes a discriminator the compiler can narrow on
class UserNotFound extends Data.TaggedError("UserNotFound")<{
  userId: string
}> {}
 
class RateLimitExceeded extends Data.TaggedError("RateLimitExceeded")<{
  retryAfter: number
}> {}
 
class ParseError extends Data.TaggedError("ParseError")<{
  reason: string
}> {}
 
type FetchUserError = UserNotFound | RateLimitExceeded | ParseError
 
// Expected behavior: catchTag handles a specific error and removes it from the E channel

Now Effect.catchTag("RateLimitExceeded", err => ...) lets you retry just that case while the type system removes the handled error from E. It feels good to watch the union shrink as you compose recovery logic. The error payloads carry whatever context you need—retryAfter for rate limits, reason for parse failures—so handlers do not need to inspect anything outside the tag itself.

As I covered in the agent resilience guide, classifying errors and giving each one a deliberate recovery strategy is what keeps systems calm in production. The taxonomy you build here ends up in your runbooks, your dashboards, and your alerts.

One caveat from the field: if your error is truly programmer-error territory—an invariant violation, a missing config—do not promote it to a tagged error. Let it be a defect that crashes loudly. The TaggedError taxonomy is for expected runtime failures the system needs to react to, not for hiding bugs behind a friendly wrapper.

Use Layer to swap production and test wiring

Layer is Effect-TS's way to express dependency construction as a value. You can plug a real HTTP client in production and a stub in tests without breaking the types, and the same approach scales to databases, queues, and any other service your code needs.

import { Layer, Effect } from "effect"
import { HttpClient } from "@effect/platform"
 
// Production: build the real HTTP client
const HttpClientLive = HttpClient.layer
 
// Tests: a stub that returns canned responses
const HttpClientTest = Layer.succeed(
  HttpClient.HttpClient,
  HttpClient.makeWith({
    request: () => Effect.succeed(mockResponse),
  })
)
 
const program = fetchUserProfile("u_123")
 
// In production:
Effect.runPromise(program.pipe(Effect.provide(HttpClientLive)))
// → hits the real API and returns a UserProfile
 
// In tests:
Effect.runPromise(program.pipe(Effect.provide(HttpClientTest)))
// → returns the canned response, no network required

If you ask Antigravity to "generate a test Layer for this program," it produces this stub pattern almost verbatim. Layer composition (Layer.merge, Layer.provideMerge) also stays well-supported because the editor reads the library's types directly. I have had the AI correctly chain three or four layers together when the requirement type spelled out the order, which is a place where pure manual TypeScript would have me reaching for documentation.

For dependencies that need initialization—say, a connection pool or a cache that warms on startup—reach for Layer.effect instead of Layer.succeed. It runs an Effect to produce the service, which means the build itself can fail with a typed error, and that failure is visible in the program's E channel. Antigravity tends to suggest the right variant once the service interface includes async setup, but if it offers Layer.succeed for something asynchronous, treat that as a hint to refine your prompt.

A practical tip: keep your live and test layers in sibling files (http.live.ts, http.test.ts). When the AI sees both side by side, it imitates the structure when you ask for new bindings, which keeps the project consistent.

Three habits that lift completion quality

A few small adjustments paid off after using Effect-TS in Antigravity for a few weeks.

First, write all of your imports up front. Effect, Data, and Layer live in similar paths, and asking the AI to figure them out mid-stream costs more than it saves. Lay the imports down first and the function bodies fall in faster, because the model can see exactly which combinators are in scope.

Second, leave a return-type comment right above the function. A single // Return type: Effect<R, E, A> line nudges Antigravity into completing the generator body with surprising precision. Once the comment exists, the AI tends to keep it accurate as the function evolves, almost like a self-maintaining docblock.

Third, pick one yield syntax across the project—either yield* _(...) or the newer underscore-less style—and stick to it. Mixing them confuses the model into suggesting both, and then you spend time deleting whichever one does not match the file. Bump tsconfig.target to ES2022 or higher and document the choice in the repo so the AI follows the same convention you do.

A fourth habit, if you have room for one: ask Antigravity to explain its own suggestions before accepting them. With Effect-TS, a tempting-looking completion sometimes hides an unhandled tag or a missing Effect.scoped. Asking "what could fail here?" prompts the AI to walk through the error channel with you, which catches gaps before they ship. The model is genuinely good at this kind of self-review when you frame it as a request rather than a follow-up after the code is committed.

The same first-class-types mindset shows up in my Zod schema-driven workflow. Whether the library is Effect-TS or Zod, anchoring the design in types is what makes AI assistance actually accelerate the work instead of papering over uncertainty.

A small step you can take today

In my experience, going all-in on Effect-TS at the start of a new project is too heavy a lift. The kinder path is to pick one function whose error handling is most tangled and rewrite only that one with Effect. Paste the original into Antigravity, ask for "Effect.gen, and represent errors as TaggedError," and you will get a strong scaffold to refine. The first conversion takes the longest because you are also picking conventions; subsequent rewrites get noticeably faster.

One pragmatic measure of progress: each rewrite should remove at least one line of try/catch and replace at least one unknown or any in the surrounding code with a concrete tagged error. If a candidate function does not let you do either, it probably is not worth converting yet—save it for after you have built up more shared layers and helpers.

If you keep a one-function-per-day pace, the most important API handlers in your service end up type-safe within a couple of weeks. Open your project, find the function with the deepest try/catch nesting, and convert that one—everything else gets easier from there once you see the pattern in your own codebase.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

App Dev2026-05-03
Building Idempotency Keys and Dedupe Stores in TypeScript with Antigravity
A production guide to designing idempotency keys and dedupe stores in TypeScript with Antigravity — covering Stripe webhook retries, Temporal replays, and the Cloudflare KV / Redis / Postgres trade-offs you actually need to choose between.
App Dev2026-05-01
Drawing the Server/Client Boundary with Antigravity — A Workflow That Stops Next.js 16 RSC From Tripping You Up
Drawing the line between Server and Client Components is still the trickiest part of Next.js 16. Here's the practical workflow I use with Antigravity to stop misplaced `use client` directives and serialization errors before they happen.
App Dev2026-04-30
Antigravity × Better Auth: Building a Modern TypeScript Auth Stack End-to-End
End the NextAuth fatigue with Better Auth. A practical Antigravity-driven guide that ships schema generation, OAuth, RBAC, and Passkeys with production-ready patterns.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →