Add focusOption
Lint / lint (push) Successful in 13s
Publish / publish (push) Successful in 19s

This commit is contained in:
Julien Valverdé
2026-05-16 13:43:02 +02:00
parent 66d40ebc01
commit a50373f88a
3 changed files with 67 additions and 0 deletions
+1
View File
@@ -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 | | | `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 | | `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 | | | `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! Also more to come!
+41
View File
@@ -183,6 +183,47 @@ describe("Lens", () => {
expect(Chunk.toReadonlyArray(updated)).toEqual([1, 2, 99]) 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.Option<number>>(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.Option<number>>(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 () => { // test("changes stream emits updates when lens mutates state", async () => {
// const events = await Effect.runPromise( // const events = await Effect.runPromise(
// Effect.flatMap( // Effect.flatMap(
+25
View File
@@ -1,4 +1,5 @@
import { Array, Chunk, Effect, Function, Option, 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 * as Cause from "effect/Cause"
import type { NoSuchElementException } from "effect/Cause" import type { NoSuchElementException } from "effect/Cause"
import * as Subscribable from "./Subscribable.js" import * as Subscribable from "./Subscribable.js"
@@ -422,6 +423,30 @@ export const focusChunkAt: {
(a, b) => Effect.succeed(Chunk.replace(a, index, b))), (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: {
<A, ER, EW, RR, RW>(
self: Lens<Option.Option<A>, ER, EW, RR, RW>,
): Lens<A, ER | NoSuchElementException, EW | NoSuchElementException, RR, RW>
} = <A, ER, EW, RR, RW>(
self: Lens<Option.Option<A>, ER, EW, RR, RW>,
): Lens<A, ER | NoSuchElementException, EW | NoSuchElementException, RR, RW> => 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`. * Reads the current value from a `Lens`.