Always human authored · no generative AI
Schrödinger's Tiger
The power of Effecful programming in Typescript
The Setup
Rather than start with the problem, I’d like to show you an example of Effectful code, and then justify why anyone would write code this way:
import { Effect, Schema } from "effect";
import { CurrentUser, Database } from "./context";
const updateUserProfile = (newProfileData: unknown) =>
Effect.gen(function* () {
const user = yield* CurrentUser;
const db = yield* Database;
const newProfile = yield* Schema.decodeUnknownEffect(UserProfileSchema)(newProfileData);
const updatedUser = yield* db.updateUserProfile(user.id, newProfile);
return Schema.encode(UserProfileSchema)(updatedUser);
});
This probably looks pretty unhinged from what we’re used to seeing in Typescript. We’re going to
break this down, so let’s start with the most basic question: what is the type signature of
updateUserProfile?
const updateUserProfile: (newProfileData: unknown) =>
Effect<string, AuthenticationError | DecodeError | DatabaseError, CurrentUser | Database>
Okay, so it returns an Effect, but it has a bunch of generic type parameters. What are those all
about? Before I explain that, I want you to take a look at a non-Effectful version of the same code.
const updateUserProfile = async (newProfileData: unknown) => {
const user = await getCurrentUser();
const db = await getDatabase();
const newProfile = validateUserProfile(newProfileData);
const updatedUser = await db.updateUserProfile(user.id, newProfile);
return JSON.stringify(updatedUser);
};
On its face, these look like the same functions, but there’s a huge difference in the type signatures, and it’s the key to why we’d write code the Effectful way. The latter version has the following type signature:
const updateUserProfile: (newProfileData: unknown) => Promise<string>
But if we think about it, this function can fail in a bunch of ways:
- The user might not be authenticated, or authorized for this action.
- The database might not be connected.
newProfileDatamight be garbase, so validation might fail.- The update action on the database could fail for any number of reasons.
But the typesignature just says, "gimme unknown, I give you Promise<string> which isn’t true at
all. So, you may think, “okay, so we’ll just get some try/catch blocks in there, and we’re gucci,
right?” From a runtime perspective, you’re right, but the type signature is still missing
information, and try/catch blocks can swallow errors, and you’d be none-the-wiser.
So let’s take a look at the type signature of the Effectful version again:
const updateUserProfile: (newProfileData: unknown) =>
Effect<string, AuthenticationError | DecodeError | DatabaseError, CurrentUser | Database>
There are three generic type parameters here, and they tell us a lot about the function.
| Success Channel | Error Channel | Dependency Channel |
|---|---|---|
| What we expect to return | How can it fail? | What dependencies / context does it need? |
string |
AuthenticationError or DecodeError or DatabaseError |
CurrentUser or Database |
So, now just by looking at the type signature, we have much richer understanding of what this function does, how it can fail, and what it needs to succeed. But that’s not all! Because what if we used this function within another Effectful function? The type signature of the outer function propagates the channels from the inner function!
const updateUserProfileAndSendEmail = (newProfileData: unknown) =>
Effect.gen(function* () {
const updatedProfile = yield* updateUserProfile(newProfileData);
yield* sendEmail(updatedProfile);
});
// The type signature of this function is:
const updateUserProfileAndSendEmail: (newProfileData: unknown) =>
Effect<
void,
AuthenticationError | DecodeError | DatabaseError | EmailError,
CurrentUser | Database | EmailService
>
So, by programming in an Effectful way, we can encapsulate success, failure, and dependency injection. The power of this cannot be understated, and more than makes up for the unfamiliar sytax.
What is an Effect?
Before we continue, it would probably be helpful to see the type signature of Effect itself:
type Effect<A, E, R> = (reqs: R) => Promise<Either<E, A>>;
Note: This is a simplification of the actual type signature for the sake of building a clear mental model. If you’re curious about the real type signature, you can find it here.
Okay, so first of all, Effects are always asynchronous, as you can see by the Promise. Here, the
Promise contains an Either type. But what is an Either? You may have seen this written as
Result elsewhere, and it’s essentially the same thing, but that doesn’t give you a whole lot of
insight. Here’s the dark secret of Effect, the thing that makes it so powerful, and the one
concept that made its complexity click for me.
Don’t panic, but Either… is a monad. And by extension… Effect is a monad.
I know, I know, monads conjure strong reactions in people who have heard of them, and confusion in those who haven’t. Regardless of your initial reaction, I promise you that you already understand enough about monads to be productive with them, and the academic ramblings of Category Theory purists shouldn’t turn you away from them.
Take a look at this code:
const someAction = () => someFailableFunction() // returns Either<MyError, MyType>
.map((result) => somePureFunction(result))
.flatMap((result) => someOtherFailableFunction(result))
.mapLeft((error) => handleError(error));
In what way is this conceptually different from:
const someAction = () => someAsyncFunction()
.then((result) => somePureFunction(result))
.then((result) => someOtherAsyncFunction(result))
.catch((error) => handleError(error));
So, if ever you’ve used Promises, you understand monads, even though Promise isn’t technically
a monad. The only real difference between a Promise and a real monad is the distinction between
map and flatMap–they’re both then in a Promise–but the underlying concept is the same.
Yes, I use em-dashes in my own writing. It’s one of my favorite punctuations, and I’m furious that they’ve been tainted by the overuse from LLM’s.
And of course, folks don’t really write asynchronouse functions this way anymore, we use
async/await. In monad terms, this is called “do notation”, and in Effect, that’s done with
Effect.gen, as demonstrated earlier.
The Monad to End All Monads
Monads have been around for a long time, but the Effect monad is genuinely special. Since it encapsulates the success, error, and dependency channels, as well as being asynchronous, it can describe any computation. Traditionally, we’d have a slew of monads for different purposes, and then you’d have to go through the trouble of using monad transformers to combine them for final output. But the Effect monad needs no such ceremony. Just describe every computation as an Effect, and the work is done for you.
If you’re still unsure as to whether this paradigm earns its keep, there is an “Effect in 2 minutes” article in the Stax docs that goes a little more in depth. You can check that out here.