The Fewest Number of Concepts You Need to Use Effect

There are 5 concepts you need to understand to start using Effect.

1. Effect.tryPromise #

Think of Effect like a souped-up Promise that’s honest about errors.

// The Promise Version of getUser
try {
  const user = await getUser("123")
  console.log(user.name)
} catch (error) {
  // Is this a network error? 404? 500? Who knows!
  console.error("Something went wrong:", error)
}

Versus…

// The Effect Version

import { Effect } from "effect"

Effect.tryPromise({
    try: () => getUser(id),
    catch: () => `DatabaseError` as const,
  });

Since we saved DbError as a TypeScript literal, hovering over this Effect will show us the error at the type level <User, DatabaseError, never>.

The first argument is the success value (the user we wanted), the second argument covers any known errors associated with this “promise” (in this case, a DatabaseError), and the third argument is the Requirements channel. We will get to the requirements channel in a moment.

2. Effect.gen (generator syntax) #

Your escape hatch from callback hell. It looks like async/await, but handles errors and dependencies automatically.

import { getUser } from "./somewhere";

  const fetchUser = Effect.gen(function* () {
    const user = yield* getUser(id); // automatically unwraps the success channel so you can code out the happy path
    if (!user) {
      return yield* new NoUserFoundError({ id });
    }
    return user;
  });

If you want to introduce a new error, you can either do it by defining a literal type, like we did in the previous step, or you can define a class that extends Data.TaggedError (from the Effect library). This is what we did for the NoUserFoundError in the snippet above.

import { Data } from "effect";

class NoUserFoundError extends Data.TaggedError("NoUserFoundError")<{
  id: string;
}> {}

3. Services (dependency injection) #

This is as complicated as it’s going to get, so bear with me here.

Say we have a database implemented somewhere…

import { drizzle } from "drizzle-orm";

  const liveDbImplementation = {
    getUser: (id) => {
      const db = drizzle(bindings);
      const result = await db
        .select({ userId: id })
        .from(users);
      return result;
    },
  };

If we want to use the database, we have to define something called a ‘service’ first. A service is just a contract that tells Effect how your database works at the type level.

Below, we’ve used the context.tag property from Effect to define a Database service (or a service with the name ‘Database’). The shape of this service is that it has one method getUser that takes an ID (which is a string), and it returns an object that has a name, ID, age, and email property on it

import { Context } from "effect"

export class Database extends Context.Tag("Database")<
    Database,
    {
      getUser: (id: string) => { 
          name: string; id:
          string; age: number; 
          email: string 
       };
    }
  >() {}

Once you have a service defined, you can use it inside an Effect.

If I want to use the database in my generator, then instead of importing getUser from somewhere, I can summon it, and it magically appears.

const fetchUser = Effect.gen(function* () {
    const { getUser } = yield* Database;
    const user = yield* getUser(id);
    if (!user) {
      return yield* Effect.fail(new NoUserFoundError({ id }));
    }
    return user;
  });

When services can be magically summoned, they will show up as a dependency in the requirements channel.

Effect<User, DatabaseError | NoUserFoundError, Database>

So now we have a user in the success channel, a database error in the error channel, a no-users-found as another possible error in the error channel, and our ‘Database’ as a dependency in the third channel.

Since we are only using the type-level service contract at the moment, you have to provide the actual implementation when you ultimately run the Effect.

Effect.runPromise(
    fetchUser.pipe(
      Effect.provideService( Database, liveDbImplementation),
    ),
  );

4. Error handling with Either/Match #

My favourite bit about using Effect is that it means no more try/catch blocks everywhere.

program.pipe(
    Effect.match({
      onFailure: (error) => {
        switch (error._tag) {
          case "DbError":
            console.error("Problem with the database");
          case "NoUserFoundError":
            console.error("no user found");
          default: {
            const _exhaustive: never = error;
            return _exhaustive;
          }
        }
      },
      onSuccess: (user) => user,
    }),
  );

When you write Effect code, you explicitly introduce all of the errors at the type level. This lets you keep track of everything so you can handle your known errors one-by-one when you ultimately run your Effect.

5. Pipe (composition) #

The glue for all of this is the ability to chain operations together. You already know composition; this is just its Effect-flavored syntax.

import { Effect } from "effect"
impor {liveDbImplementation} from './wherever'

Effect.runPromise(
    Effect.gen(function* () {
      const { getUser } = yield* Database;
      const user = yield* getUser(id);
      if (!user) {
        return yield* new NoUserFoundError({ id });
      }
      return user;
    }).pipe(
      Effect.provideService(Database, liveDbImplementation),
      Effect.match({
        onFailure: (error) => {
          switch (error._tag) {
            case "DbError":
              console.error("Problem with the database");
            case "NoUserFoundError":
              console.error("no user found");
            default: {
              const _exhaustive: never = error;
              return _exhaustive;
            }
          }
        },
        onSuccess: (user) => user,
      }),
    ),
  );

We’re calling Effect.RunPromise so that we get the actual result we want from the generator Effect. Then we pipe in any dependencies we need. Finally, we handle all of our tracked errors.

What just happened? #

  1. Effect.tryPromise - This lets you slowly start refactoring all your promise-based code into Effects, so that you can start to encode known errors around your async code into your type system.

  2. Effect.gen - Looks like async/await, but errors and dependencies flow through automatically. This is how you’ll be writing most of your code now.

  3. Services - Swap DbLive for DbTest without touching registerUser. This is a little complicated at first, but then it keeps everything manageable when complexity ramps up.

  4. Pattern Matching Errors - Typesafe error handling 🙌…otherwise what’s the point?

  5. Pipe - Chain Effect operations cleanly: Provide dependancies → handle errors → run

Understanding these five concepts alone should get you 80% of what Effect has to offer. All your errors can now be handled at the type level, and all your dependencies will be injected.

Interestingly, you’ll begin to catch yourself stubbing out services with interfaces so you can jump straight to composing your business logic. Delaying implementation like this lets you spend more time with your domain upfront, and often leads to cleaner, more testable code.

This is Effect at its core.

         ┌─── Represents the success type
         │        ┌─── Represents the error type
         │        │      ┌─── Represents required dependencies
         ▼        ▼      ▼
Effect<Success, Error, Requirements>

Thats it.

 
8
Kudos
 
8
Kudos

Now read this

React + Firestore : Get Setup In Five Steps

One of the biggest barriers to learning React used to be the overwhelming amount of tooling involved. Beginners were forced to make important decisions about things they don’t understand before they could create anything. React solved... Continue →