diff --git a/packages/effect-lens/README.md b/packages/effect-lens/README.md index e7a7bad..c976d80 100644 --- a/packages/effect-lens/README.md +++ b/packages/effect-lens/README.md @@ -165,6 +165,7 @@ Currently available: | `focusTupleAt` | Focuses to an indexed entry of a readonly tuple. Replaces the parent tuple immutably when writing to the focused index | Immutable | | | `focusMutableTupleAt` | Focuses to an indexed entry of a mutable tuple. Mutates the parent tuple in place at the focused index | Mutable | Type-safe: will not allow you to mutate `readonly` tuples | | `focusChunkAt` | Focuses to an indexed entry of a `Chunk`. Replaces the parent `Chunk` immutably when writing to the focused element | Immutable | | +| `focusOption` | Focuses to the value inside an `Option`. Wraps writes back into `Option.some` | Immutable | Reading or writing fails with `NoSuchElementException` when the parent option is `None` | Also more to come! diff --git a/packages/effect-lens/src/Lens.test.ts b/packages/effect-lens/src/Lens.test.ts index ea0b5e9..fabe248 100644 --- a/packages/effect-lens/src/Lens.test.ts +++ b/packages/effect-lens/src/Lens.test.ts @@ -183,6 +183,47 @@ describe("Lens", () => { expect(Chunk.toReadonlyArray(updated)).toEqual([1, 2, 99]) }) + test("focusOption reads and writes the inner Some value", async () => { + const result = await Effect.runPromise( + Effect.flatMap( + SubscriptionRef.make>(Option.some(42)), + parent => { + const lens = Lens.focusOption(Lens.fromSubscriptionRef(parent)) + return Effect.flatMap( + Lens.get(lens), + value => Effect.flatMap( + Lens.set(lens, 100), + () => Effect.map(parent.get, parentValue => [value, parentValue] as const), + ), + ) + }, + ), + ) + + expect(result[0]).toBe(42) + expect(result[1]).toEqual(Option.some(100)) + }) + + test("focusOption fails when the parent option is None", async () => { + const result = await Effect.runPromise( + Effect.flatMap( + SubscriptionRef.make>(Option.none()), + parent => { + const lens = Lens.focusOption(Lens.fromSubscriptionRef(parent)) + return Effect.all([ + Effect.either(Lens.get(lens)), + Effect.either(Lens.set(lens, 100)), + parent.get, + ] as const) + }, + ), + ) + + expect(result[0]._tag).toBe("Left") + expect(result[1]._tag).toBe("Left") + expect(result[2]).toEqual(Option.none()) + }) + // test("changes stream emits updates when lens mutates state", async () => { // const events = await Effect.runPromise( // Effect.flatMap( diff --git a/packages/effect-lens/src/Lens.ts b/packages/effect-lens/src/Lens.ts index 0f1a6a8..10371df 100644 --- a/packages/effect-lens/src/Lens.ts +++ b/packages/effect-lens/src/Lens.ts @@ -1,4 +1,5 @@ import { Array, Chunk, Effect, Function, Option, Pipeable, Predicate, Readable, Stream, type SubscriptionRef, type SynchronizedRef } from "effect" +import * as Cause from "effect/Cause" import type { NoSuchElementException } from "effect/Cause" import * as Subscribable from "./Subscribable.js" @@ -422,6 +423,30 @@ export const focusChunkAt: { (a, b) => Effect.succeed(Chunk.replace(a, index, b))), ) +/** + * Narrows the focus to the value inside an `Option`. + * + * Reading or writing through this lens fails with `NoSuchElementException` when the parent option is `None`. + * Writing wraps the new focused value back into `Option.some`. + */ +export const focusOption: { + ( + self: Lens, ER, EW, RR, RW>, + ): Lens +} = ( + self: Lens, ER, EW, RR, RW>, +): Lens => mapEffect( + self, + option => Option.match(option, { + onSome: value => Effect.succeed(value), + onNone: () => Effect.fail(new Cause.NoSuchElementException()), + }), + (option, value) => Option.match(option, { + onSome: () => Effect.succeed(Option.some(value)), + onNone: () => Effect.fail(new Cause.NoSuchElementException()), + }), +) + /** * Reads the current value from a `Lens`.