Skip to content
February 9, 2024

My impressions on Effect-TS

Working with Effect-TS, a powerful library for building applications in TypeScript.

There have been some breaking changes since I wrote this post (2.3 & 2.4), as well as the stable release of version 3.0. I will update this article soonish.

when will TS be able to trace throws and show you what errors a function might throw
this feature would also help the whole ecosystem to throw properly typed errors
Dax (@thdxr)

Rapid fire questions:

  • Why use Effect-ts?: Because I want to treat errors as values and nicely handle them.
  • Is it functional programming mambo jambo?: I write my code using Effect-ts in an imperative way. It's inspired by functional libraries, it gives you all the tools to write in a functional way, but it's not dogmatic. If anything, sometimes using Classes is more convenient.
  • Do I have to rewrite everything?: No, you can scope it to a part of your app, or even a single function. I purposely avoid using it everywhere and keep it to the parts of my app that make sense for it.
  • Is it hard to learn?: It's not easy, but it's not hard. It depends on how deep you want to go. The documentation is good, and the community is very helpful.

Alright, let's dive in.

Error handling

Let's take a look at this example:

export function createInvitation({pool, db}: {pool: PgPool; db: DB}) {
  function execute({
    props: {email, role},
    userId,
    orgId,
  }: {
    props: CreateInvitationProps;
    userId: User['id'];
    orgId: Org['id'];
  }) {
    return Effect.gen(function* (_) {
      yield* _(
        Effect.log(`(create-invitation): Creating invitation for ${email}`)
      );
 
      yield* _(
        invitationAuthorizationService({pool, db}).canCreate({userId, orgId})
      );
 
      const invitationId = yield* _(generateUUID());
 
      // Delete any previous invitation
      yield* _(
        Effect.tryPromise({
          try: () => db.deletes('membership_invitations', {org_id: orgId, email}).run(pool),
          catch: () => new DatabaseError(),
        })
      );
 
      const existingMember = yield* _(
        Effect.tryPromise({
          try: () =>
            db
              .selectOne(
                'users',
                {email: email},
                {
                  lateral: {
                    membership: db.selectOne('memberships', {
                      org_id: orgId,
                      user_id: db.parent('id'),
                    }),
                  },
                }
              )
              .run(pool),
          catch: () => new DatabaseError(),
        })
      );
 
      if (existingMember?.membership) {
        return yield* _(Effect.fail(new InviteeAlreadyMemberError()));
      }
 
      const invitationRecord = yield* _(
        Effect.tryPromise({
          try: () =>
            db
              .insert('membership_invitations', {
                id: invitationId,
                role: role,
                email: email,
                org_id: orgId,
              })
              .run(pool),
          catch: () => {
            return new DatabaseError();
          },
        })
      );
 
      const invitation = yield* _(MembershipInvitation.fromRecord(invitationRecord));
 
      return invitation;
    }).pipe(
      Effect.catchTags({
        DatabaseError: () =>
          Effect.fail(new InternalServerError({reason: 'Database error'})),
        MembershipInvitationParse: () =>
          Effect.fail(
            new InternalServerError({
              reason: 'Membership invitation parse error',
            })
          ),
        UUIDGenerationError: () =>
          Effect.fail(
            new InternalServerError({reason: 'UUID generation error'})
          ),
      })
    );
  }
}

It's a big one. I'm not going to "Hello World" you. I know I'm mixing concerns; some might say I should split it into smaller functions, pull an ORM, or do things differently. But I'm not here to talk about that.

Chances are that generators aside, you can read it just fine. There's some syntax sugar, but it's familiar.

  • I log some stuff
  • I check if the User can create an invitation (throws ForbiddenActionError) otherwise
  • I generate a UUID (throws UUIDGenerationError, impossible to happen IRL, but maybe my version of uuid is broken)
  • I delete any previous invitation (throws DatabaseError, could be much more refined)
  • I check if the User is already a member (throws InviteeAlreadyMemberError)
  • I insert the invitation (throws DatabaseError)
  • I parse the invitation (throws MembershipInvitationParse) to the types that the frontend understands
  • I return the invitation

If I hover over execute, I see this function signature (you might have to scroll a bit):

(local function) execute({ props: { email, role }, userId, orgId, }: {
    props: CreateInvitationProps;
    userId: User['id'];
    orgId: Org['id'];
}): Effect.Effect<never, InternalServerError | ForbiddenActionError | InviteeAlreadyMemberError | OrgNotFoundError, MembershipInvitation>

The above means that my function has the following properties:

  • No dependencies (never as the first type parameter. Let's discuss that in a second)
  • Can error with InternalServerError, ForbiddenActionError, InviteeAlreadyMemberError, OrgNotFoundError
  • Returns a MembershipInvitation

For me, this is a big deal. I can see at a glance what my function does and what can go wrong. I don't have to read the implementation to identify the errors. More importantly, I don't have to be defensive when using this function. I have a clear contract.

In the example, even though my other functions throw UUIDGenerationError and MembershipInvitationParse, I don't find these errors meaningful for the consumer. So I catch them and group them under InternalServerError. The rest can go through. The consumer then can decide on the appropriate server response based on what happened.

  • ForbiddenActionError -> 403
  • InviteeAlreadyMemberError -> 409
  • OrgNotFoundError -> 404 (or even a full redirect to /login)
  • InternalServerError -> 500

Now, let's take a closer look at one of these errors. Specifically the InternalServerError. I defined it as a class and can pass a reason and metadata to it. Nothing will reach the consumer; they are meant to be logged and reported.

export class InternalServerError extends Data.TaggedClass(
  'InternalServerError'
)<{
  readonly reason?: string;
  readonly metadata?: unknown;
}> {
  constructor(props: {reason?: string; metadata?: unknown}) {
    super(props);
    console.log('[InternalServerError]', props);
    // send to reporting service
  }
}
 

Schema

As you move further into Effect-ts, you might notice that it also makes various other libraries obsolete. One of them is zod which I can replace with Schema. Let's take a look at my User class:

import type {ParseError} from '@effect/schema/ParseResult';
import * as Schema from '@effect/schema/Schema';
import {Data, Effect} from 'effect';
import {compose} from 'effect/Function';
import type {users} from 'zapatos/schema';
 
import {db} from '~/core/db/schema.server.ts';
 
import {emailSchema} from './email.server.ts';
import {uuidSchema} from './uuid.server.ts';
 
class UserIdParseError extends Data.TaggedError('UserIdParseError')<{
  cause: ParseError;
}> {}
 
class UserParseError extends Data.TaggedError('UserParseError')<{
  cause: ParseError;
}> {}
 
export const userNameSchema = Schema.Trim.pipe(
  Schema.minLength(2, {
    message: () => 'Name must be at least 2 characters',
  }),
  Schema.maxLength(100, {
    message: () => 'Name cannot be more than 100 characters',
  })
);
 
const UserIdBrand = Symbol.for('UserIdBrand');
export const userIdSchema = uuidSchema.pipe(Schema.brand(UserIdBrand));
 
export class User extends Schema.Class<User>()({
  id: userIdSchema,
  name: userNameSchema,
  email: emailSchema,
  emailVerified: Schema.boolean,
  createdAt: Schema.Date,
  updatedAt: Schema.Date,
}) {
  static fromUnknown = compose(
    Schema.decodeUnknown(this),
    Effect.mapError((cause) => new UserParseError({cause}))
  );
 
  static fromRecord(record: users.JSONSelectable) {
    return User.fromUnknown({
      id: record.id,
      name: record.name,
      email: record.email,
      emailVerified: record.email_verified,
      createdAt: record.created_at,
      updatedAt: record.updated_at,
    });
  }
 
  getRecord() {
    return {
      id: this.id,
      name: this.name,
      email: this.email,
      email_verified: this.emailVerified,
      updated_at: db.toString(this.updatedAt, 'timestamptz'),
      created_at: db.toString(this.createdAt, 'timestamptz'),
    };
  }
}
 
export const parseUserId = compose(
  Schema.decodeUnknown(userIdSchema),
  Effect.mapError((cause) => new UserIdParseError({cause}))
);

Again too much code, but I want to show you the Schema part. I define the schema for my User class, and then I can use it to parse a database record or an unknown object.

Just like that...

// Effect.Effect<never, UserParseError, User>
const user = User.fromUnknown(unknownObject);

If something odd happens, I get a UserParseError, and I can handle it accordingly.

You might noticed that I use Brand to create a new type for my UserId. It is a convenient feature, and I blogged about it here. Essentially, I never want to confuse a UserId with an OrgId or an AnnouncementId. They are all UUIDs, but they are identifiers of different resources.


Let's take a look at a password module. I use bcrypt to hash and compare passwords and Schema to validate them.

import type {ParseError} from '@effect/schema/ParseResult';
import * as Schema from '@effect/schema/Schema';
import bcrypt from 'bcryptjs';
import {Data, Effect} from 'effect';
import {compose, pipe} from 'effect/Function';
 
const SALT_ROUNDS = 10;
const PasswordBrand = Symbol.for('PasswordBrand');
 
export const passwordSchema = Schema.string.pipe(
  Schema.minLength(8, {
    message: () => 'Password should be at least 8 characters long',
  }),
  Schema.maxLength(100, {
    message: () => 'Password should be at most 100 characters long',
  }),
  Schema.brand(PasswordBrand)
);
 
export type Password = Schema.Schema.To<typeof passwordSchema>;
 
export class PasswordHashError {
  readonly _tag = 'PasswordHashError';
}
class PasswordParseError extends Data.TaggedError('PasswordParseError')<{
  cause: ParseError;
}> {}
 
export const parsePassword = compose(
  Schema.decodeUnknown(passwordSchema),
  Effect.mapError((cause) => new PasswordParseError({cause}))
);
 
export function hashPassword(password: Password) {
  return pipe(
    Effect.tryPromise(() => bcrypt.hash(password, SALT_ROUNDS)),
    Effect.mapError(() => new PasswordHashError())
  );
}
 
export function comparePasswords({
  plainText,
  hashValue,
}: {
  plainText: string;
  hashValue: string;
}) {
  return Effect.tryPromise({
    try: () => bcrypt.compare(plainText, hashValue),
    catch: () => new PasswordHashError(),
  });
}

And this is where I stop

I mentioned that Effect-ts has a lot of features (Queues , PubSub , Scheduling , Streams , Dependency Injection, etc)

Here's the deal. I showed you some of the parts that I use. For me, they work nicely, and they solve my problems. But I'm certain that I'm not doing things the conventional way. Here's an example:

Remember this type signature?

Effect.Effect<never, InternalServerError | ForbiddenActionError | InviteeAlreadyMemberError | OrgNotFoundError, MembershipInvitation>

I said the first type parameter is a union of the dependencies, and in this case, it's empty, just never. I'm using a database, and a query builder (specifically the Zapatos library), so why is it not listed? Effect-ts offers a way to do dependency injection, and it's nice. But I prefer not to do it that way. I pass the pool and db as arguments to my function instead.


I'm trying to figure out how to put this; I want to use Effect-ts but not to be tied to it. While I was trying to understand and use the library, I found myself in a rabbit hole. I was always searching for how to do it the right way. And this led me to look at other people's snippets and repos, and I became overwhelmed. I didn't understand half of it, and I always thought I was doing everything wrong.

Instead of using a library, I spent my time trying to learn a new framework, refactoring code, and rewiring my brain. My side project was on hold, and I was not productive. (Oh, the horror)

I decided to use Effect-ts for validation and error handling. Essentially write the annoying code that can be modeled more efficienty with Effect, and use runPromiseExit to get the result. I'm losing a lot of the library's power, but I also reduce the surface of the things I have to learn and maintain.

If things don't work out, I can keep the Error classes, replace the validation library, and remove the yield* and Effect.gen, and I'm back where I started. I won't have invested that much.

Don't get me wrong, I can only speak highly of the Effect and am grateful for the maintainers. If anything, I hope it will get more traction, as it's a fantastic piece of software. If my team was using Effect, I would 100% use it to its full potential and I would be singing a different song.

i love @EffectTS_ it's the exact mental model i want
but regrettably i'll never put it in a serious application
libraries that make your code look non-standard always ends up creating pain down the road
hardest part about js is convincing devs to use fewer tools
Dax (@thdxr)

Community

Effect has a vibrant community; you can find them on Discord.

I have only good things to say. I have asked a ton of stupid questions and always got a response. I even had feature requests that were implemented in a matter of days.

There are also great people tweeting about it, so you can follow them and get some insights. Here are some of them:


Resources