Tom MacWright

2025@macwright.com

Effect notes: v4, Effect.promise, and TaggedEnum

An update since the last Effect devlog:

I finally vanquished the last bits of neverthrow from the codebase and replaced them with Effect abstractions. neverthrow was great for what we used it for - I'd still use it on a simpler project, but Effect is a better fit for Val Town.

The LLMs are getting incrementally better at writing Effect code: they now do a decent job of annotating functions with explicit type parameters, but they still leave some refactoring debt where they call Effect.runPromise repeatedly instead of combining calls with Effect.gen.

Effect v4… I'm not using it yet. The beta has been the focus of the Effect team since February. Old-fashioned Effect v3 is only getting backported fixes. A lot of the community is using v4 and living on the edge. I'm not using v4 for the simple reasons: I don't want to use beta modules with lots of churn in a production application, and v4 is not documented.

The documentation problem is still pretty bad. The JSDoc issue got closed. The SQL-is-undocumented issue got closed in anticipation of the v4 release and the upcoming focus on documentation. This team is producing incredible code and I hope that they can produce incredible docs to match, but the fear that documentation simply isn't highly valued or prioritized persists because I've seen so little improvement.

I've written some more ast-grep rules for Effect. We have a big codebase and there are lots of ways to screw up. Some of the ways that I've recently automatically prevented are:

Throwing in Effect.gen! This is going to be refactoring debt one way or another. If you run into an issue, you yield an error, not throw:

Here's my rule:

id: dont-throw-in-effect
language: Tsx
rule:
  pattern: Effect.gen($$$A)
  has:
    pattern: throw $A
    stopBy: end

Using Effect.promise. Early on, I used this method a bunch because I was being lazy. The LLMs probably used it too. I've learned that it's a footgun: if the promise it wraps ever rejects, that produces a defect, not a failure, and that goes through a totally different error handling path. We should always use Effect.tryPromise.

Here's the rule:

id: no-effect-promise
language: Tsx
rule:
  pattern: Effect.promise($A)
fix: 'Effect.tryPromise({ try: $A, catch(error) { return new UnexpectedError({message: "Unexpectedly failed", cause: error }) } })'

And then update the "Unexpectedly failed" message with something more descriptive.

Data.TaggedEnum is the new toy. I use a lot of discriminated unions. If there's a x in y type check, that's a code smell to me and we should use a type or kind field to differentiate the different object shapes instead. TaggedEnum cuts some of the boilerplate for that and adds some in another area. I like it but I'm not 100% sure the abstraction pays for itself yet.


At this stage, Effect is pretty productive, though a lot of our initial issues remain. The application is far from being a 'pure' Effect app, but most important database-touching, authentication-enforcing, or business logic-producting methods are using Effect's abstractions. I have no regrets about the switch.