Layer.catchAll

Building a layer is an effectful operation and, as such, things can go wrong. Effect’s type system encourages us to acknowledge these errors and handle them at the most appropriate point in our system.

We typically build Layers at the boundary of our application. If the layer fails to build we may wish to

  • terminate the program immediately, because we cannot continue
  • retry building the layer until it succeeds
  • provide some fallback implementation

Much like Effect.catchAll (8) , there are specific operators for dealing with errors relating to our layer.

Consider a Cache, where the happy path is to connect to a remote cache.

import { Context, Data, Effect } from "effect";
export class CacheError extends Data.TaggedError("@fred.c/CacheError")<{
cause: unknown;
operation: "connect" | "get" | "set";
}> {}
export interface CacheShape {
get: (key: string) => Effect.Effect<unknown, CacheError>;
set: (key: string, value: unknown) => Effect.Effect<void, CacheError>;
}
export class Cache extends Context.Tag("@fred.c/Cache")<Cache, CacheShape>() {}

We can implement a remote cache implementation. In this example I am wrapping a hypothetical third-party cache library which has a Promise-based API.

import { Effect, Layer } from "effect";
import orm from "./orm";
import { getEnvironmentVariable, MissingEnvironmentVariable } from "./lib";
import { Cache, CacheError, type CacheShape } from "./service";
const makeRemote = Effect.gen(function* () {
const url = yield* getEnvironmentVariable("CACHE_URL");
const connection = yield* Effect.tryPromise({
try: () => orm.connect(url),
catch: (cause) => new CacheError({ cause, operation: "connect" }),
});
return {
get: (key) =>
Effect.tryPromise({
try: () => connection.get(key),
catch: (cause) => new CacheError({ cause, operation: "get" }),
}),
set: (key, value) =>
Effect.tryPromise({
try: () => connection.set(key, value),
catch: (cause) => new CacheError({ cause, operation: "get" }),
}),
} satisfies CacheShape;
});
// ┌─── Layer.Layer<Cache, CacheError | MissingEnvironmentVariable, never>
// ▼
const layerRemote = Layer.effect(Cache, makeRemote);

If our remote Cache is critical, then we may decide the easiest option is to bail out. “Let it crash” can be a valid error handling strategy. But we could at least print out a message explaining why our application crashed.

// ┌─── Layer.Layer<Cache, CacheError | MissingEnvironmentVariable, never>
// ▼
const layerRemoteWithDebug = layerRemote.pipe(
Layer.catchAll((e) => {
console.error(
`Unable to connect to remote cache due to ${e.name} (${e.cause})`
);
return Layer.fail(e);
})
);

Much like Effect.catchAll (8) this operator accepts an error and should return a Layer. In this first example it returns a Layer of the exact same type, but this need not be the case.

Another option could be to fall back gracefully, switching to an in-memory cache if the remote cache is unavailable.

const makeInMemory = Effect.sync(() => {
const contents: Record<string, unknown> = {};
return {
get: (key) => Effect.sync(() => contents[key]),
set: (key, value) =>
Effect.sync(() => {
contents[key] = value;
}),
} satisfies CacheShape;
});
const layerInMemory = Layer.effect(Cache, makeInMemory);
// ┌─── Layer.Layer<Cache, never, never>
// ▼
const layerRemoteWithFallback = layerRemote.pipe(
Layer.catchAll(() => layerInMemory)
);

Our error-handling strategy may also depend on the type of error. We may decide that a MissingEnvironmentVariable is a catastrophic config error that we cannot recover from, but a CacheError is a transient error that we expect to disappear before long. Here is a very naive implementation of “retry”:

const layerRemoteInfiniteRetry: Layer.Layer<Cache, MissingEnvironmentVariable> =
layerRemote.pipe(
Layer.catchAll((e) => {
if (e instanceof CacheError) {
return layerRemoteInfiniteRetry;
}
return Layer.fail(e);
})
);

We will see better ways to implement retry logic in future lessons. In particular, retrying immediately may be a very bad strategy indeed.

Layer.catchAll is quite a blunt instrument. There are many Effect-based operators for dealing with errors, which may be a better option. For example, it might be easier to implement retry at the Effect level, before converting to a Layer. Effect.catchTag (13) makes the retry logic simpler.

const makeRemoteInfiniteRetry: Effect.Effect<
CacheShape,
MissingEnvironmentVariable
> = makeRemote.pipe(
Effect.catchTag("@fred.c/CacheError", () => makeRemoteInfiniteRetry)
);
export const layerRemoteInfiniteRetry2 = Layer.effect(
Cache,
makeRemoteInfiniteRetry
);

Observe that by wrapping the third-party library in Effect, we have been forced to acknowledge the possible errors. We can still choose not to handle the errors, and let them bubble out to the edge of our application, but we also have powerful options to track the possible errors throughout the whole codebase.

Let’s recap:

  • Layers can fail to build successfully
  • Layer.catchAll allows us to handle these errors however we want
  • It may be easier to deal with errors using Effect operators instead of Layer operators
  • The Effect ecosystem encourages us to think carefully about error handling logic