From c6313cd27dd66563ffb3d8a01f1d60053c8e80ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Sat, 28 Mar 2026 21:13:30 +0100 Subject: [PATCH] 0.1.3 (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Julien Valverdé Reviewed-on: https://git.valverde.cloud/Thilawyn/effect-lens/pulls/2 --- packages/effect-lens/package.json | 2 +- packages/effect-lens/src/Lens.test.ts | 52 +++++++++++++++++++- packages/effect-lens/src/Lens.ts | 68 ++++++++++++++++++++++++++- 3 files changed, 119 insertions(+), 3 deletions(-) diff --git a/packages/effect-lens/package.json b/packages/effect-lens/package.json index 51e8dea..f5b0f5f 100644 --- a/packages/effect-lens/package.json +++ b/packages/effect-lens/package.json @@ -1,7 +1,7 @@ { "name": "effect-lens", "description": "An effectful Lens type to easily manage nested state", - "version": "0.1.2", + "version": "0.1.3", "type": "module", "files": [ "./README.md", diff --git a/packages/effect-lens/src/Lens.test.ts b/packages/effect-lens/src/Lens.test.ts index 57eee62..33d3191 100644 --- a/packages/effect-lens/src/Lens.test.ts +++ b/packages/effect-lens/src/Lens.test.ts @@ -1,9 +1,59 @@ import { describe, expect, test } from "bun:test" -import { Chunk, Effect, SubscriptionRef } from "effect" +import { Chunk, Effect, Option, SubscriptionRef } from "effect" import * as Lens from "./Lens.js" describe("Lens", () => { + test("mapOption transforms Some values and preserves None", async () => { + const result = await Effect.runPromise( + Effect.flatMap( + SubscriptionRef.make>(Option.some(42)), + parent => { + const lens = Lens.mapOption( + Lens.fromSubscriptionRef(parent), + n => n * 2, + (_n, doubled) => doubled / 2, + ) + return Effect.flatMap( + Lens.get(lens), + value => Effect.flatMap( + Lens.set(lens, Option.some(100)), + () => Effect.map(parent.get, parentValue => [value, parentValue] as const), + ), + ) + }, + ), + ) + + expect(result[0]).toEqual(Option.some(84)) // 42 * 2 + expect(result[1]).toEqual(Option.some(50)) // 100 / 2 + }) + + test("mapOptionEffect transforms Some values with effects", async () => { + const result = await Effect.runPromise( + Effect.flatMap( + SubscriptionRef.make>(Option.some(42)), + parent => { + const lens = Lens.mapOptionEffect( + Lens.fromSubscriptionRef(parent), + n => Effect.succeed(n * 2), + (_n, doubled) => Effect.succeed(doubled / 2), + ) + return Effect.flatMap( + Lens.get(lens), + value => Effect.flatMap( + Lens.set(lens, Option.some(100)), + () => Effect.map(parent.get, parentValue => [value, parentValue] as const), + ), + ) + }, + ), + ) + + expect(result[0]).toEqual(Option.some(84)) // 42 * 2 + expect(result[1]).toEqual(Option.some(50)) // 100 / 2 + }) + test("focusObjectField focuses a nested property without touching other fields", async () => { const [initialCount, updatedState] = await Effect.runPromise( Effect.flatMap( diff --git a/packages/effect-lens/src/Lens.ts b/packages/effect-lens/src/Lens.ts index 679e0dc..f265eee 100644 --- a/packages/effect-lens/src/Lens.ts +++ b/packages/effect-lens/src/Lens.ts @@ -1,4 +1,4 @@ -import { Array, Chunk, Effect, Function, Pipeable, Predicate, Readable, Stream, type SubscriptionRef, type SynchronizedRef } from "effect" +import { Array, Chunk, Effect, Function, Option, Pipeable, Predicate, Readable, Stream, type SubscriptionRef, type SynchronizedRef } from "effect" import type { NoSuchElementException } from "effect/Cause" import * as Subscribable from "./Subscribable.js" @@ -183,6 +183,72 @@ export const mapEffect: { )), })) +/** + * Derives a new `Lens` by applying synchronous getters and setters over the value inside an `Option`. + * + * Similar to `Option.map`, this preserves the `Option` structure: + * - If the `Option` is `Some(a)`, applies the getter and setter to the inner value + * - If the `Option` is `None`, it remains `None` + */ +export const mapOption: { + ( + self: Lens, ER, EW, RR, RW>, + get: (a: NoInfer) => B, + set: (a: NoInfer, b: B) => NoInfer, + ): Lens, ER, EW, RR, RW> + ( + get: (a: NoInfer) => B, + set: (a: NoInfer, b: B) => NoInfer, + ): (self: Lens, ER, EW, RR, RW>) => Lens, ER, EW, RR, RW> +} = Function.dual(3, ( + self: Lens, ER, EW, RR, RW>, + get: (a: NoInfer) => B, + set: (a: NoInfer, b: B) => NoInfer, +): Lens, ER, EW, RR, RW> => map( + self, + Option.map(get), + (opt, newOpt) => Option.match(opt, { + onSome: a => Option.map(newOpt, b => set(a, b)), + onNone: () => Option.none(), + }), +)) + +/** + * Derives a new `Lens` by applying effectful getters and setters over the value inside an `Option`. + * + * Similar to `Option.map`, this preserves the `Option` structure: + * - If the `Option` is `Some(a)`, applies the effectful getter and setter to the inner value + * - If the `Option` is `None`, it remains `None` + */ +export const mapOptionEffect: { + ( + self: Lens, ER, EW, RR, RW>, + get: (a: NoInfer) => Effect.Effect, + set: (a: NoInfer, b: B) => Effect.Effect, ESet, RSet>, + ): Lens, ER | EGet, EW | ESet, RR | RGet, RW | RSet> + ( + get: (a: NoInfer) => Effect.Effect, + set: (a: NoInfer, b: B) => Effect.Effect, ESet, RSet>, + ): (self: Lens, ER, EW, RR, RW>) => Lens, ER | EGet, EW | ESet, RR | RGet, RW | RSet> +} = Function.dual(3, ( + self: Lens, ER, EW, RR, RW>, + get: (a: NoInfer) => Effect.Effect, + set: (a: NoInfer, b: B) => Effect.Effect, ESet, RSet>, +): Lens, ER | EGet, EW | ESet, RR | RGet, RW | RSet> => mapEffect( + self, + Option.match({ + onSome: a => Effect.map(get(a), Option.some), + onNone: () => Effect.succeed(Option.none()), + }), + (opt, newOpt) => Option.match(opt, { + onSome: a => Option.match(newOpt, { + onSome: b => Effect.map(set(a, b), Option.some), + onNone: () => Effect.succeed(Option.none()), + }), + onNone: () => Effect.succeed(Option.none()), + }), +)) + /** * Allows transforming only the `changes` stream of a `Lens` while keeping the focus type intact. */