Skip to content

Writing

TypeScript Without Surprises: How I Use ts-pattern, ts-belt and Effect

A walk through my open-source boilerplate, standard: where ts-pattern replaces if and switch, where ts-belt replaces array methods, and how Effect handles errors, services and transactions in the API.

TypeScript Without Surprises: How I Use ts-pattern, ts-belt and Effect, by Maulana Sodiqin

standard is the boilerplate I start new projects from: Hono and oRPC on the API, Drizzle, better-auth, and a React 19 SPA on TanStack Router. Three libraries show up in almost every file. ts-pattern replaces most of my if and switch statements, ts-belt replaces the array and object methods, and Effect runs the business logic in the API.

This post goes through how they are used in that repo, with the actual code. Versions at the time of writing: ts-pattern 5.9, @mobily/ts-belt 3.13, and Effect 4.0 (still a release candidate).

ts-pattern

The rule in standard is that anything with more than one meaningful branch goes through match. A plain ternary is still fine for a simple true/false.

The login form is a good example of why. After signIn.email returns, there are four outcomes: a two-factor challenge, a successful sign-in whose session we still have to confirm, a session that could not be reached, and an error. Written as a match on a small object, the cases read in order:

const { data, error: signInError } = await authClient.signIn.email(value);

await match({ error: signInError, pending: isTwoFactorPending(data) })
  .with({ error: P.nullish, pending: true }, async () => {
    await navigate({ to: "/two-factor", search: { redirect } });
  })
  .with({ error: P.nullish }, async () => {
    const resolution = await sessionRefresh();

    await match(resolution)
      .with({ reach: SESSION_REACH.UNREACHABLE }, async () => {
        loginError.set(AUTH_MESSAGE.SESSION_UNREACHABLE);
      })
      .with({ session: P.nullish }, async () => {
        loginError.set(AUTH_MESSAGE.SESSION_UNVERIFIED);
      })
      .otherwise(async () => {
        await navigate({ href: returnToResolve(redirect) });
      });
  })
  .otherwise(async ({ error: found }) => {
    await verificationResendIfNeeded(found ?? {}, value.email);
    loginError.set(signInErrorMessage(found ?? {}));
  });

I match on constants like SESSION_REACH.UNREACHABLE rather than string literals. If someone renames the value, a literal in a .with() still compiles and just stops matching. A constant breaks the build, which is what I want.

The place ts-pattern earns its keep is .exhaustive(). The API maps every domain error to an oRPC error in one function:

const toORPCError = (error: TDomainError): ORPCError<string, undefined> =>
  match(error)
    .with(
      { _tag: ERROR_TAG.NOT_FOUND },
      (e) => new ORPCError("NOT_FOUND", { message: e.message }),
    )
    .with(
      { _tag: ERROR_TAG.FORBIDDEN },
      (e) => new ORPCError("FORBIDDEN", { message: e.message }),
    )
    // UNAUTHORIZED, CONFLICT and BAD_REQUEST follow the same shape
    .with(
      {
        _tag: P.union(
          ERROR_TAG.DATABASE,
          ERROR_TAG.AUTH,
          ERROR_TAG.QUEUE,
          ERROR_TAG.STORAGE,
        ),
      },
      () =>
        new ORPCError("INTERNAL_SERVER_ERROR", {
          message: ERROR_MESSAGE.INTERNAL,
        }),
    )
    .exhaustive();

When I add a new error class to the union, this function stops compiling until I decide what the client should see. The P.union arm groups the infrastructure failures so none of them leak their details.

The downside is verbosity. A three-line if can turn into ten lines of match, and nested matches like the login form get deep quickly. I accept that for logic with real branches. For a single condition I still write a ternary.

ts-belt

ts-belt gives you A for arrays, D for objects, O for optional values and a few others, all as plain functions. In standard I use them instead of Array.prototype and Object.*. Most of the time that is just A.map and A.filter, but it pays off when a transformation has a few steps.

The roles page groups permissions like note:read and note:update by their resource:

const permissionResource = (permission: TPermission): string =>
  A.head(S.split(permission, ":")) ?? permission;

const RESOURCES: readonly string[] = A.uniq(
  A.map(ALL_PERMISSIONS, permissionResource),
);

export const PERMISSION_GROUPS: readonly TPermissionGroup[] = A.map(
  RESOURCES,
  (resource) => ({
    resource,
    permissions: A.filter(
      ALL_PERMISSIONS,
      (permission) => permissionResource(permission) === resource,
    ),
  }),
);

O is useful at the edges where a value may not exist. The server-side table only forwards a sort change if the column is one the API allows:

onSortingChange: (updater): void => {
  const first = A.head(resolveSorting(updater, sorting));
  const sortBy = A.find(options.sortKeys, (key) => key === first?.id);
  O.match(
    sortBy,
    (key): void =>
      options.onChange({
        sortBy: key,
        sortDir: directionOf(first?.desc === true),
        page: 1,
      }),
    (): void => undefined,
  );
},

The two libraries combine well. The activity log turns a metadata object into a readable line, and falls back to a placeholder when there is nothing to show:

export const metadataLabel = (metadata: TActivity["metadata"]): string =>
  match(metadata)
    .with(P.nullish, (): string => NOT_SET)
    .otherwise((found): string =>
      match(A.flat(A.map(DETAIL_ORDER, (spec) => partOf(found, spec))))
        .when(A.isEmpty, (): string => NOT_SET)
        .otherwise((parts): string => A.join(parts, PART_SEPARATOR)),
    );

One problem I ran into: the ESM build of ts-belt 3.13 does not load under plain Node, because its barrel file imports directories without an index.js. standard carries a small pnpm patch that rewrites those imports. If you see a module resolution error from @mobily/ts-belt on the server, that is the cause.

Effect

Effect is the biggest of the three, and the one I limit the most. It only runs in the API's business logic: use cases, repositories, and the services they depend on. React, the oRPC and Hono wiring, and scripts all stay on plain async/await.

Errors

Expected failures are tagged classes instead of thrown exceptions:

export class ENotFound extends Schema.TaggedError<ENotFound>()(
  ERROR_TAG.NOT_FOUND,
  {
    message: Schema.String,
  },
) {}

export class EDatabase extends Schema.TaggedError<EDatabase>()(
  ERROR_TAG.DATABASE,
  {
    cause: Schema.Defect(),
  },
) {}

All of them go into a TDomainError union, which is what the toORPCError match above is exhaustive over.

Services and layers

A repository is a Context.Service tag declared as a const, and its Drizzle implementation is a Layer next to it. Every query goes through Effect.tryPromise, so a driver error becomes an EDatabase rather than an exception:

export const NoteRepo = Context.Service<TNoteRepoId, TNoteRepo>(
  REPO_TAG.NOTE,
);

export const noteRepoLayer = Layer.effect(
  NoteRepo,
  Effect.gen(function* () {
    const { db } = yield* DbService;

    const findById: TNoteRepo["findById"] = (id, actor) =>
      Effect.tryPromise({
        try: async () => {
          const [row] = await dbActive(db)
            .select()
            .from(note)
            .where(
              and(eq(note.id, id), ownershipWhere(actor, note.authorId)),
            )
            .limit(1);
          return row ?? null;
        },
        catch: (cause) => new EDatabase({ cause }),
      });

    // list, create, update and remove are built the same way
  }),
);

TNoteRepoId is a phantom type. Without it, two services with the same shape would be interchangeable in Effect's requirements type, and the compiler would not notice if I provided the wrong one.

A use case

Updating a note looks like this:

export const noteUpdate = Effect.fn("noteUpdate")(function* (
  input: TNoteUpdateInput,
  actor: TOwnershipActor,
): Effect.fn.Return<
  TNote,
  ENotFound | EConflict | EDatabase,
  TNoteRepoId | TActivityRecorderId
> {
  const noteRepo = yield* NoteRepo;
  const activityRepo = yield* ActivityRecorder;
  const previous = yield* noteRepo.findById(input.id, actor);

  if (previous === null) {
    return yield* new ENotFound({ message: NOTE_MESSAGE.NOT_FOUND });
  }

  const updated = yield* noteRepo.update(input, actor);

  if (updated === null) {
    const current = yield* noteRepo.findById(input.id, actor);

    if (current === null) {
      return yield* new ENotFound({ message: NOTE_MESSAGE.NOT_FOUND });
    }

    return yield* new EConflict({ message: NOTE_MESSAGE.CONFLICT });
  }

  yield* activityRepo.insert({
    actorId: actor.id,
    action: ACTIVITY_ACTION.NOTE_UPDATE,
    resourceType: ACTIVITY_RESOURCE_TYPE.NOTE,
    resourceId: updated.id,
    metadata: updateDetails(previous, updated),
  });

  return toNoteDto(updated);
});

The return type lists what it can fail with and what it needs. The update is optimistic: if it matched no row, the note was either deleted or changed by someone else, and the second lookup decides which error to return.

These are the only if statements in the codebase that I do not turn into match. Effect's own guidance asks for return yield* new SomeError(...) inside a guard, so TypeScript can see that the function stops there.

The audit detail at the end uses ts-belt to list the fields that changed:

const updateDetails = (previous: TNoteRow, next: TNoteRow): TActivityDetails =>
  activityDetails({
    [ACTIVITY_DETAIL.TITLE]: next.title,
    [ACTIVITY_DETAIL.CHANGED_FIELDS]: activityDetailList(
      A.filter(
        D.values(NOTE_FIELD),
        (field: TNoteField): boolean => previous[field] !== next[field],
      ),
    ),
  });

Libraries that are not Effect-aware

better-auth returns promises. I do not try to make the whole library work with Effect. The auth service wraps the one call it needs and handles the shapes it can return:

const getSession: TAuthService["getSession"] = (headers: Headers) =>
  Effect.tryPromise({
    try: () => auth.api.getSession({ headers }),
    catch: (cause) => new EAuth({ cause }),
  }).pipe(
    Effect.flatMap(
      (result): TSessionEffect =>
        match(result)
          .with(P.nullish, (): TSessionEffect => Effect.succeed(null))
          .with(
            { session: P.nullish },
            (): TSessionEffect => Effect.succeed(null),
          )
          .with(
            { user: P.nullish },
            (): TSessionEffect => Effect.succeed(null),
          )
          .otherwise(({ user }) =>
            sessionBuild(user, user.role ?? ROLE.VIEWER),
          ),
    ),
  );

Running it

Each module exports a layer, and the composition root merges them into one runtime:

export const AppLayer = userModule.layer.pipe(
  Layer.provideMerge(authModule.layer),
  Layer.provideMerge(moduleLayer),
  Layer.provideMerge(platformLayer),
);

export const runtime = ManagedRuntime.make(AppLayer, {
  memoMap: appMemoMap,
});

Effect programs only run in one function, called from every oRPC handler. It turns expected errors into a value before calling runPromise, so the promise can only reject on a real defect, and then throws the mapped oRPC error:

export const effectRun = async <A>(
  runtime: TAppRuntime,
  effect: Effect.Effect<A, TDomainError, TAppRuntimeServices>,
): Promise<A> => {
  const result = await runtime.runPromise(
    effect.pipe(
      Effect.map((value): TResult<A> => ({ _tag: "success", value })),
      Effect.catch(
        (error): Effect.Effect<TResult<A>> =>
          Effect.succeed({ _tag: "failure", error }),
      ),
    ),
  );

  return match(result)
    .with({ _tag: "success" }, ({ value }) => value)
    .with({ _tag: "failure" }, ({ error }): never => {
      throw toORPCError(error);
    })
    .exhaustive();
};

Transactions

The most involved piece is transactional, which runs an Effect inside a Drizzle transaction. Drizzle rolls back when the callback throws, so a failed Effect is thrown as a marker object and turned back into its Exit outside the transaction:

const transactionRun = async <A, E, R>(
  db: TDb,
  services: Context.Context<R>,
  effect: Effect.Effect<A, E, R>,
): Promise<Exit.Exit<A, E>> => {
  try {
    return await db.transaction(async (tx): Promise<Exit.Exit<A, E>> => {
      const exit = await transactionStorage.run(tx as TDb, () =>
        Effect.runPromiseExitWith(services)(effect),
      );

      return match(Exit.isFailure(exit))
        .with(true, (): Exit.Exit<A, E> => {
          throw rollbackOf(exit);
        })
        .otherwise((): Exit.Exit<A, E> => exit);
    });
  } catch (cause) {
    return match(cause)
      .when(
        isRollback,
        (found): Exit.Exit<A, E> => found.exit as Exit.Exit<A, E>,
      )
      .otherwise((): never => {
        throw cause;
      });
  }
};

The transaction handle travels through AsyncLocalStorage, so repositories pick it up without every use case passing a tx around.

Tests

Because dependencies come from the context, a unit test provides fake layers instead of mocking modules:

const testLayer = Layer.merge(
  Layer.succeed(
    NoteRepo,
    NoteRepo.of({
      list: vi.fn(),
      findById,
      create: vi.fn(),
      update,
      remove: vi.fn(),
    }),
  ),
  Layer.succeed(ActivityRecorder, ActivityRecorder.of({ insert })),
);

const result = await Effect.runPromise(
  noteUpdate(INPUT, OTHER_ACTOR).pipe(Effect.provide(testLayer)),
);

What I would tell someone starting out

Effect took the longest to get comfortable with. Generators, the requirements type and layers all feel strange for the first week or two. Effect 4 is also still a release candidate, and most articles online describe version 3, so I read the docs that ship inside the installed package instead of relying on what I remember.

I would add ts-pattern to any TypeScript project without much thought. ts-belt is worth it once you have real transformations to write. Effect I would only bring into the part of the code that has many use cases sharing services and many ways to fail, which in standard is the API and nothing else.

The conventions only hold because they are written down. standard keeps them in one ruleset that every change is checked against, including when to use each library and the exceptions, like the if guards in Effect code.