diff --git a/packages/effect-lens/src/Lens.test.ts b/packages/effect-lens/src/Lens.test.ts index 0bd111c..58b118f 100644 --- a/packages/effect-lens/src/Lens.test.ts +++ b/packages/effect-lens/src/Lens.test.ts @@ -1,11 +1,128 @@ import { describe, expect, test } from "bun:test" -import { Chunk, Context, Effect, Option, SubscriptionRef } from "effect" +import { Chunk, Context, Effect, Option, Stream, SubscriptionRef } from "effect" import * as Lens from "./Lens.js" describe("Lens", () => { class Offset extends Context.Tag("Offset")() {} + test("mapErrorRead transforms read errors", async () => { + const lens = Lens.mapErrorRead( + Lens.make({ + get: Effect.fail("read" as const), + changes: Stream.fail("read" as const), + set: () => Effect.void, + }), + error => `mapped:${ error }`, + ) + + const result = await Effect.runPromise(Effect.either(Lens.get(lens))) + + expect(result.left).toBe("mapped:read") + }) + + test("mapErrorWrite transforms modify errors", async () => { + const lens = Lens.mapErrorWrite( + Lens.make({ + get: Effect.succeed(1), + changes: Stream.make(1), + set: () => Effect.fail("write" as const), + }), + () => "mapped-write", + ) + + const result = await Effect.runPromise(Effect.either(Lens.set(lens, 2))) + + expect(result.left).toBe("mapped-write") + }) + + test("mapError transforms read and modify errors", async () => { + const lens = Lens.mapError( + Lens.make({ + get: Effect.fail("read" as const), + changes: Stream.fail("read" as const), + set: () => Effect.fail("write" as const), + }), + () => "mapped", + ) + + const result = await Effect.runPromise(Effect.all([ + Effect.either(Lens.get(lens)), + Effect.either(Lens.set(lens, 1)), + ] as const)) + + expect(result[0].left).toBe("mapped") + expect(result[1].left).toBe("mapped") + }) + + test("catchAllRead recovers from read failures", async () => { + const result = await Effect.runPromise( + Effect.flatMap( + SubscriptionRef.make(42), + fallback => Lens.get( + Lens.catchAllRead( + Lens.make({ + get: Effect.fail("read" as const), + changes: Stream.fail("read" as const), + set: () => Effect.void, + }), + () => Lens.fromSubscriptionRef(fallback), + ), + ), + ), + ) + + expect(result).toBe(42) + }) + + test("tapErrorRead runs an effect on read failures", async () => { + const result = await Effect.runPromise( + Effect.flatMap( + SubscriptionRef.make(0), + counter => { + const lens = Lens.tapErrorRead( + Lens.make({ + get: Effect.fail("read" as const), + changes: Stream.fail("read" as const), + set: () => Effect.void, + }), + () => SubscriptionRef.modify(counter, n => [void 0, n + 1] as const), + ) + return Effect.flatMap( + Effect.either(Lens.get(lens)), + () => counter.get, + ) + }, + ), + ) + + expect(result).toBe(1) + }) + + test("tapErrorWrite runs an effect on modify failures", async () => { + const result = await Effect.runPromise( + Effect.flatMap( + SubscriptionRef.make(0), + counter => { + const lens = Lens.tapErrorWrite( + Lens.make({ + get: Effect.succeed(1), + changes: Stream.make(1), + set: () => Effect.fail("write" as const), + }), + () => SubscriptionRef.modify(counter, n => [void 0, n + 1] as const), + ) + return Effect.flatMap( + Effect.either(Lens.set(lens, 2)), + () => counter.get, + ) + }, + ), + ) + + expect(result).toBe(1) + }) + test("mapOption transforms Some values and preserves None", async () => { const result = await Effect.runPromise( Effect.flatMap( diff --git a/packages/effect-lens/src/Lens.ts b/packages/effect-lens/src/Lens.ts index 4a23c27..1468d87 100644 --- a/packages/effect-lens/src/Lens.ts +++ b/packages/effect-lens/src/Lens.ts @@ -270,6 +270,173 @@ export const mapStream: { })) +/** + * Transforms read errors of a `Lens`. + * + * Applies to `get` and `changes` while leaving `modify` unchanged. + */ +export const mapErrorRead: { + ( + self: Lens, + f: (error: NoInfer) => E2, + ): Lens + ( + f: (error: NoInfer) => E2, + ): (self: Lens) => Lens +} = Function.dual(2, ( + self: Lens, + f: (error: NoInfer) => E2, +): Lens => make({ + get get() { return Effect.mapError(self.get, f) }, + get changes() { return Stream.mapError(self.changes, f) }, + get modify() { return self.modify as any }, +})) + +/** + * Transforms modify errors of a `Lens`. + * + * Applies to the `modify` effect. Since `modify` may also fail with errors coming from the + * user-supplied callback, the handler receives `unknown`. + */ +export const mapErrorWrite: { + ( + self: Lens, + f: (error: unknown) => E2, + ): Lens + ( + f: (error: unknown) => E2, + ): (self: Lens) => Lens +} = Function.dual(2, ( + self: Lens, + f: (error: unknown) => E2, +): Lens => make({ + get get() { return self.get }, + get changes() { return self.changes }, + modify: ( + g: (a: A) => Effect.Effect + ) => Effect.mapError(self.modify(g), f), +})) + +/** + * Transforms all errors of a `Lens`. + * + * Applies to `get`, `changes`, and `modify`. Since `modify` may also fail with errors coming + * from the user-supplied callback, the handler receives `unknown`. + */ +export const mapError: { + ( + self: Lens, + f: (error: unknown) => E2, + ): Lens + ( + f: (error: unknown) => E2, + ): (self: Lens) => Lens +} = Function.dual(2, ( + self: Lens, + f: (error: unknown) => E2, +): Lens => make({ + get get() { return Effect.mapError(self.get, f) }, + get changes() { return Stream.mapError(self.changes, f) }, + modify: ( + g: (a: A) => Effect.Effect + ) => Effect.mapError(self.modify(g), f), +})) + +/** + * Recovers from read failures of a `Lens`. + * + * Applies to `get` and `changes` while leaving `modify` unchanged. + */ +export const catchAllRead: { + ( + self: Lens, + f: (error: NoInfer) => Subscribable.Subscribable, + ): Lens + ( + f: (error: NoInfer) => Subscribable.Subscribable, + ): (self: Lens) => Lens +} = Function.dual(2, ( + self: Lens, + f: (error: NoInfer) => Subscribable.Subscribable, +): Lens => make({ + get get() { return Effect.catchAll(self.get, error => f(error).get) }, + get changes() { return Stream.catchAll(self.changes, error => f(error).changes) }, + get modify() { return self.modify as any }, +})) + +/** + * Runs an effect when read failures occur. + * + * Applies to `get` and `changes` while leaving `modify` unchanged. + */ +export const tapErrorRead: { + ( + self: Lens, + f: (error: NoInfer) => Effect.Effect, + ): Lens + ( + f: (error: NoInfer) => Effect.Effect, + ): (self: Lens) => Lens +} = Function.dual(2, ( + self: Lens, + f: (error: NoInfer) => Effect.Effect, +): Lens => make({ + get get() { return Effect.tapError(self.get, f) }, + get changes() { return Stream.tapError(self.changes, f) }, + get modify() { return self.modify as any }, +})) + +/** + * Runs an effect when modify failures occur. + * + * Applies to the `modify` effect. Since `modify` may also fail with errors coming from the + * user-supplied callback, the handler receives `unknown`. + */ +export const tapErrorWrite: { + ( + self: Lens, + f: (error: unknown) => Effect.Effect, + ): Lens + ( + f: (error: unknown) => Effect.Effect, + ): (self: Lens) => Lens +} = Function.dual(2, ( + self: Lens, + f: (error: unknown) => Effect.Effect, +): Lens => make({ + get get() { return self.get }, + get changes() { return self.changes }, + modify: ( + g: (a: A) => Effect.Effect + ) => Effect.tapError(self.modify(g), f), +})) + +/** + * Runs an effect when any `Lens` failure occurs. + * + * Applies to `get`, `changes`, and `modify`. Since `modify` may also fail with errors coming + * from the user-supplied callback, the handler receives `unknown`. + */ +export const tapError: { + ( + self: Lens, + f: (error: unknown) => Effect.Effect, + ): Lens + ( + f: (error: unknown) => Effect.Effect, + ): (self: Lens) => Lens +} = Function.dual(2, ( + self: Lens, + f: (error: unknown) => Effect.Effect, +): Lens => make({ + get get() { return Effect.tapError(self.get, f) }, + get changes() { return Stream.tapError(self.changes, f) }, + modify: ( + g: (a: A) => Effect.Effect + ) => Effect.tapError(self.modify(g), f), +})) + + /** * Provides a `Context` to a `Lens`, removing it from both the read and write environments. */