From 2fde98c367a5af619f961d7609ed441b27495ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 27 Mar 2026 11:21:03 +0100 Subject: [PATCH 1/8] Add Subscribable module --- packages/effect-lens/src/Lens.ts | 17 ++++---- packages/effect-lens/src/Subscribable.ts | 53 ++++++++++++++++++++++++ packages/effect-lens/src/index.ts | 1 + 3 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 packages/effect-lens/src/Subscribable.ts diff --git a/packages/effect-lens/src/Lens.ts b/packages/effect-lens/src/Lens.ts index a3daf7e..4901de8 100644 --- a/packages/effect-lens/src/Lens.ts +++ b/packages/effect-lens/src/Lens.ts @@ -1,5 +1,6 @@ -import { Array, Chunk, Effect, Function, Pipeable, Predicate, Readable, Stream, Subscribable, type SubscriptionRef, type SynchronizedRef } from "effect" +import { Array, Chunk, Effect, Function, Pipeable, Predicate, Readable, Stream, type SubscriptionRef, type SynchronizedRef } from "effect" import type { NoSuchElementException } from "effect/Cause" +import * as Subscribable from "./Subscribable.js" export const LensTypeId: unique symbol = Symbol.for("@effect-fc/Lens/Lens") @@ -206,7 +207,7 @@ export const mapStream: { /** * Narrows the focus to a field of an object. Replaces the object in an immutable fashion when written to. */ -export const focusField: { +export const focusObjectField: { ( self: Lens, key: K, @@ -223,7 +224,7 @@ export const focusField: { (a, b) => Object.setPrototypeOf({ ...a, [key]: b }, Object.getPrototypeOf(a)), )) -export declare namespace focusMutableField { +export declare namespace focusObjectMutableField { export type WritableKeys = { [K in keyof T]-?: IfEquals< { [P in K]: T[K] }, @@ -239,15 +240,15 @@ export declare namespace focusMutableField { /** * Narrows the focus to a mutable field of an object. Mutates the object in place when written to. */ -export const focusMutableField: { - , ER, EW, RR, RW>( +export const focusObjectMutableField: { + , ER, EW, RR, RW>( self: Lens, key: K, ): Lens - , ER, EW, RR, RW>( + , ER, EW, RR, RW>( key: K, ): (self: Lens) => Lens -} = Function.dual(2, , ER, EW, RR, RW>( +} = Function.dual(2, , ER, EW, RR, RW>( self: Lens, key: K, ): Lens => map(self, a => a[key], (a, b) => { a[key] = b; return a })) @@ -259,7 +260,7 @@ export const focusArrayAt: { ( self: Lens, index: number, - ): Lens + ): Lens ( index: number ): (self: Lens) => Lens diff --git a/packages/effect-lens/src/Subscribable.ts b/packages/effect-lens/src/Subscribable.ts new file mode 100644 index 0000000..125198e --- /dev/null +++ b/packages/effect-lens/src/Subscribable.ts @@ -0,0 +1,53 @@ +import { Array, Chunk, Function, Subscribable } from "effect" +import type { NoSuchElementException } from "effect/Cause" + + +export * from "effect/Subscribable" + +/** + * Narrows the focus to a field of an object. + */ +export const focusObjectField: { + ( + self: Subscribable.Subscribable, + key: K, + ): Subscribable.Subscribable + ( + key: K, + ): (self: Subscribable.Subscribable) => Subscribable.Subscribable +} = Function.dual(2, ( + self: Subscribable.Subscribable, + key: K, +): Subscribable.Subscribable => Subscribable.map(self, a => a[key])) + +/** + * Narrows the focus to an indexed element of an array. + */ +export const focusArrayAt: { + ( + self: Subscribable.Subscribable, + index: number, + ): Subscribable.Subscribable + ( + index: number + ): (self: Subscribable.Subscribable) => Subscribable.Subscribable +} = Function.dual(2, ( + self: Subscribable.Subscribable, + index: number, +): Subscribable.Subscribable => Subscribable.mapEffect(self, Array.get(index))) + +/** + * Narrows the focus to an indexed element of `Chunk`. + */ +export const focusChunkAt: { + ( + self: Subscribable.Subscribable, E, R>, + index: number, + ): Subscribable.Subscribable + ( + index: number + ): (self: Subscribable.Subscribable, E, R>) => Subscribable.Subscribable +} = Function.dual(2, ( + self: Subscribable.Subscribable, E, R>, + index: number, +): Subscribable.Subscribable => Subscribable.mapEffect(self, Chunk.get(index))) diff --git a/packages/effect-lens/src/index.ts b/packages/effect-lens/src/index.ts index 2b9139b..6eec098 100644 --- a/packages/effect-lens/src/index.ts +++ b/packages/effect-lens/src/index.ts @@ -1,2 +1,3 @@ export * as Lens from "./Lens.js" export * as PropertyPath from "./PropertyPath.js" +export * as Subscribable from "./Subscribable.js" -- 2.49.1 From 8572bb1f6e3d9a6f5421657e17c94502f6adf60c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 27 Mar 2026 13:24:14 +0100 Subject: [PATCH 2/8] Update docs --- packages/effect-lens/README.md | 38 +++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/packages/effect-lens/README.md b/packages/effect-lens/README.md index 3fa5b96..eeac5c1 100644 --- a/packages/effect-lens/README.md +++ b/packages/effect-lens/README.md @@ -2,8 +2,6 @@ A Lens type for [Effect](https://effect.website/) to easily manage nested state. -A proper documentation is currently being written. In the meantime, you can take a look at the quickstart below and at the `packages/example` directory. - ## Install ``` npm install effect-lens @@ -140,7 +138,7 @@ const ref = yield* SubscriptionRef.make<{ // \/ Lens const jeanDupontLens = ref.pipe( Lens.fromSubscriptionRef, // Creates a lens that proxies the ref - Lens.focusField("users"), // Creates a focused lens that points to the users field + Lens.focusObjectField("users"), // Creates a focused lens that points to the users field Lens.focusArrayAt(0), // Creates a focused lens that points to the first entry of the user array ) // Reading or writing from this lense can fail with NoSuchElementException @@ -150,7 +148,7 @@ const jeanDupont = yield* Lens.get(jeanDupontLens) yield* Lens.set( // You can focus even further down - Lens.focusField(jeanDupontLens, "age"), + Lens.focusObjectField(jeanDupontLens, "age"), yield* DateTime.make("03/25/1970"), ) // Mutations with the parent state are performed immutably by default @@ -160,8 +158,8 @@ yield* Lens.set( Currently available: | Name | Description | Parent state mutation behavior | Notes | | - | - | - | - | -| `focusField` | Focuses to the field of an object. Replaces the parent object immutably when writing to the focused field | Immutable | | -| `focusMutableField` | Focuses to the field of an object. Mutates the parent object in place via the writable field | Mutable | Type-safe: will not allow you to mutate `readonly` fields | +| `focusObjectField` | Focuses to the field of an object. Replaces the parent object immutably when writing to the focused field | Immutable | | +| `focusObjectMutableField` | Focuses to the field of an object. Mutates the parent object in place via the writable field | Mutable | Type-safe: will not allow you to mutate `readonly` fields | | `focusArrayAt` | Focuses to an indexed entry of an array. Replaces the parent array immutably when writing to the focused index | Immutable | | | `focusMutableArrayAt` | Focuses to an indexed entry of an array. Mutates the parent array in place at the focused index | Mutable | Type-safe: will not allow you to mutate `readonly` arrays | | `focusChunkAt` | Focuses to an indexed entry of a `Chunk`. Replaces the parent `Chunk` immutably when writing to the focused element | Immutable | | @@ -171,11 +169,6 @@ Also more to come! #### Manually You can create focused Lenses by composing them manually using `map`, `mapEffect` and `unwrap`: ```typescript -interface User { - readonly name: string - readonly age: DateTime.Utc -} - const ref = yield* SubscriptionRef.make([ { name: "Jean Dupont", age: yield* DateTime.make("03/25/1969") }, { name: "Juan Joya Borja", age: yield* DateTime.make("04/05/1956") }, @@ -221,11 +214,32 @@ const someFunctionThatShouldOnlyHaveReadonlyAccessToTheState = ( const lens = ref.pipe( Lens.fromSubscriptionRef, - Lens.focusField("users"), + Lens.focusObjectField("users"), ) yield* someFunctionThatShouldOnlyHaveReadonlyAccessToTheState(lens) ``` +#### Focusing +This library re-exports Effect's `Subscribable` module and adds a few transforms to narrow the focus of `Subscribable`'s, same as Lenses: +```typescript +import { Subscribable } from "effect-lens" + +declare const sub: Subscribabe.Subscribable + +// \/ Subscribabe.Subscribable +const nameSub = sub.pipe( + Subscribable.focusArrayAt(1), + Subscribable.focusObjectField("name"), +) +``` + +Currently available: +| Name | Description | +| - | - | +| `focusObjectField` | Focuses to the field of an object | +| `focusArrayAt` | Focuses to an indexed entry of an array | +| `focusChunkAt` | Focuses to an indexed entry of a `Chunk` | + ## Todo -- 2.49.1 From b2cf51c393ecaeb51d436919c9d42acf1cea2041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 27 Mar 2026 13:34:03 +0100 Subject: [PATCH 3/8] Add tuple transforms --- packages/effect-lens/src/Lens.test.ts | 44 ++++++++++++++++++++++++--- packages/effect-lens/src/Lens.ts | 43 ++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/packages/effect-lens/src/Lens.test.ts b/packages/effect-lens/src/Lens.test.ts index 208d74a..6d5613a 100644 --- a/packages/effect-lens/src/Lens.test.ts +++ b/packages/effect-lens/src/Lens.test.ts @@ -4,12 +4,12 @@ import * as Lens from "./Lens.js" describe("Lens", () => { - test("focusField focuses a nested property without touching other fields", async () => { + test("focusObjectField focuses a nested property without touching other fields", async () => { const [initialCount, updatedState] = await Effect.runPromise( Effect.flatMap( SubscriptionRef.make({ count: 1, label: "original" }), parent => { - const countLens = Lens.focusField(Lens.fromSubscriptionRef(parent), "count") + const countLens = Lens.focusObjectField(Lens.fromSubscriptionRef(parent), "count") return Effect.flatMap( Lens.get(countLens), count => Effect.flatMap( @@ -25,13 +25,13 @@ describe("Lens", () => { expect(updatedState).toEqual({ count: 6, label: "original" }) }) - test("focusMutableField preserves the root identity when mutating in place", async () => { + test("focusObjectMutableField preserves the root identity when mutating in place", async () => { const original = { detail: "keep" } const updated = await Effect.runPromise( Effect.flatMap( SubscriptionRef.make(original), parent => { - const detailLens = Lens.focusMutableField(Lens.fromSubscriptionRef(parent), "detail") + const detailLens = Lens.focusObjectMutableField(Lens.fromSubscriptionRef(parent), "detail") return Effect.flatMap( Lens.set(detailLens, "mutated"), () => parent.get, @@ -80,6 +80,42 @@ describe("Lens", () => { expect(updated).toEqual(["baz", "bar"]) }) + test("focusTupleAt updates the selected tuple index immutably", async () => { + const updated = await Effect.runPromise( + Effect.flatMap( + SubscriptionRef.make(["a", "b", "c"] as const), + parent => { + const elementLens = Lens.focusTupleAt(Lens.fromSubscriptionRef(parent), 1) + return Effect.flatMap( + Lens.set(elementLens, "updated"), + () => parent.get, + ) + }, + ), + ) + + expect(updated).toEqual(["a", "updated", "c"]) + }) + + test("focusMutableTupleAt mutates the tuple reference in place", async () => { + const original = ["foo", "bar"] as ["foo", "bar"] + const updated = await Effect.runPromise( + Effect.flatMap( + SubscriptionRef.make(original), + parent => { + const elementLens = Lens.focusMutableTupleAt(Lens.fromSubscriptionRef(parent), 0) + return Effect.flatMap( + Lens.set(elementLens, "baz"), + () => parent.get, + ) + }, + ), + ) + + expect(updated).toBe(original) + expect(updated).toEqual(["baz", "bar"]) + }) + test("focusChunkAt replaces the focused chunk element", async () => { const updated = await Effect.runPromise( Effect.flatMap( diff --git a/packages/effect-lens/src/Lens.ts b/packages/effect-lens/src/Lens.ts index 4901de8..d7f7c6f 100644 --- a/packages/effect-lens/src/Lens.ts +++ b/packages/effect-lens/src/Lens.ts @@ -296,6 +296,49 @@ export const focusMutableArrayAt: { ), )) +/** + * Narrows the focus to an indexed element of a readonly tuple. Replaces the tuple in an immutable fashion when written to. + */ +export const focusTupleAt: { + ( + self: Lens, + index: number, + ): Lens + ( + index: number + ): (self: Lens) => Lens +} = Function.dual(2, ( + self: Lens, + index: number, +): Lens => mapEffect( + self, + Array.get(index), + (a, b) => Array.replaceOption(a, index, b) as any, +)) + +/** + * Narrows the focus to an indexed element of a mutable tuple. Mutates the tuple in place when written to. + */ +export const focusMutableTupleAt: { + ( + self: Lens, + index: number, + ): Lens + ( + index: number + ): (self: Lens) => Lens +} = Function.dual(2, ( + self: Lens, + index: number, +): Lens => mapEffect( + self, + Array.get(index), + (a, b) => Effect.flatMap( + Array.get(a, index), + () => Effect.as(Effect.sync(() => { a[index] = b }), a), + ), +)) + /** * Narrows the focus to an indexed element of `Chunk`. Replaces the `Chunk` in an immutable fashion when written to. */ -- 2.49.1 From d2e4875b8a81b1d2b811f7c1e9499d2083ce9331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 27 Mar 2026 13:48:45 +0100 Subject: [PATCH 4/8] Fix --- packages/effect-lens/src/Lens.test.ts | 4 +-- packages/effect-lens/src/Lens.ts | 47 +++++++++++++-------------- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/packages/effect-lens/src/Lens.test.ts b/packages/effect-lens/src/Lens.test.ts index 6d5613a..57eee62 100644 --- a/packages/effect-lens/src/Lens.test.ts +++ b/packages/effect-lens/src/Lens.test.ts @@ -83,7 +83,7 @@ describe("Lens", () => { test("focusTupleAt updates the selected tuple index immutably", async () => { const updated = await Effect.runPromise( Effect.flatMap( - SubscriptionRef.make(["a", "b", "c"] as const), + SubscriptionRef.make(["a", "b", "c"]), parent => { const elementLens = Lens.focusTupleAt(Lens.fromSubscriptionRef(parent), 1) return Effect.flatMap( @@ -98,7 +98,7 @@ describe("Lens", () => { }) test("focusMutableTupleAt mutates the tuple reference in place", async () => { - const original = ["foo", "bar"] as ["foo", "bar"] + const original: [string, string] = ["foo", "bar"] const updated = await Effect.runPromise( Effect.flatMap( SubscriptionRef.make(original), diff --git a/packages/effect-lens/src/Lens.ts b/packages/effect-lens/src/Lens.ts index d7f7c6f..679e0dc 100644 --- a/packages/effect-lens/src/Lens.ts +++ b/packages/effect-lens/src/Lens.ts @@ -300,43 +300,40 @@ export const focusMutableArrayAt: { * Narrows the focus to an indexed element of a readonly tuple. Replaces the tuple in an immutable fashion when written to. */ export const focusTupleAt: { - ( + ( self: Lens, - index: number, - ): Lens - ( - index: number - ): (self: Lens) => Lens -} = Function.dual(2, ( + index: I, + ): Lens + ( + index: I + ): (self: Lens) => Lens +} = Function.dual(2, ( self: Lens, - index: number, -): Lens => mapEffect( + index: I, +): Lens => map( self, - Array.get(index), - (a, b) => Array.replaceOption(a, index, b) as any, + Array.unsafeGet(index), + (a, b) => Array.replace(a, index, b) as any, )) /** * Narrows the focus to an indexed element of a mutable tuple. Mutates the tuple in place when written to. */ export const focusMutableTupleAt: { - ( + ( self: Lens, - index: number, - ): Lens - ( - index: number - ): (self: Lens) => Lens -} = Function.dual(2, ( + index: I, + ): Lens + ( + index: I + ): (self: Lens) => Lens +} = Function.dual(2, ( self: Lens, - index: number, -): Lens => mapEffect( + index: I, +): Lens => map( self, - Array.get(index), - (a, b) => Effect.flatMap( - Array.get(a, index), - () => Effect.as(Effect.sync(() => { a[index] = b }), a), - ), + Array.unsafeGet(index), + (a, b) => { a[index] = b; return a }, )) /** -- 2.49.1 From 9f5b65c2c2508fd8c852a7eef135a915eec64a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 27 Mar 2026 14:21:30 +0100 Subject: [PATCH 5/8] Add Subscribable.focusTupleAt --- packages/effect-lens/src/Subscribable.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/effect-lens/src/Subscribable.ts b/packages/effect-lens/src/Subscribable.ts index 125198e..c88a108 100644 --- a/packages/effect-lens/src/Subscribable.ts +++ b/packages/effect-lens/src/Subscribable.ts @@ -36,6 +36,22 @@ export const focusArrayAt: { index: number, ): Subscribable.Subscribable => Subscribable.mapEffect(self, Array.get(index))) +/** + * Narrows the focus to an indexed element of a readonly tuple. + */ +export const focusTupleAt: { + ( + self: Subscribable.Subscribable, + index: I, + ): Subscribable.Subscribable + ( + index: I + ): (self: Subscribable.Subscribable) => Subscribable.Subscribable +} = Function.dual(2, ( + self: Subscribable.Subscribable, + index: I, +): Subscribable.Subscribable => Subscribable.map(self, Array.unsafeGet(index))) + /** * Narrows the focus to an indexed element of `Chunk`. */ -- 2.49.1 From 9e0083d9f798f22fa8a488513730615911336964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 27 Mar 2026 14:25:32 +0100 Subject: [PATCH 6/8] Update docs --- packages/effect-lens/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/effect-lens/README.md b/packages/effect-lens/README.md index eeac5c1..6f94f9f 100644 --- a/packages/effect-lens/README.md +++ b/packages/effect-lens/README.md @@ -159,9 +159,11 @@ Currently available: | Name | Description | Parent state mutation behavior | Notes | | - | - | - | - | | `focusObjectField` | Focuses to the field of an object. Replaces the parent object immutably when writing to the focused field | Immutable | | -| `focusObjectMutableField` | Focuses to the field of an object. Mutates the parent object in place via the writable field | Mutable | Type-safe: will not allow you to mutate `readonly` fields | +| `focusObjectMutableField` | Focuses to the writable field of an object. Mutates the parent object in place via the writable field | Mutable | Type-safe: will not allow you to mutate `readonly` fields | | `focusArrayAt` | Focuses to an indexed entry of an array. Replaces the parent array immutably when writing to the focused index | Immutable | | | `focusMutableArrayAt` | Focuses to an indexed entry of an array. Mutates the parent array in place at the focused index | Mutable | Type-safe: will not allow you to mutate `readonly` arrays | +| `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 | | Also more to come! -- 2.49.1 From 5e79d6b9434be4410865cabb71e6fdc955ced873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 27 Mar 2026 14:28:41 +0100 Subject: [PATCH 7/8] Update docs --- packages/effect-lens/README.md | 1 + packages/effect-lens/package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/effect-lens/README.md b/packages/effect-lens/README.md index 6f94f9f..9046d54 100644 --- a/packages/effect-lens/README.md +++ b/packages/effect-lens/README.md @@ -240,6 +240,7 @@ Currently available: | - | - | | `focusObjectField` | Focuses to the field of an object | | `focusArrayAt` | Focuses to an indexed entry of an array | +| `focusTupleAt` | Focuses to an indexed entry of a tuple | | `focusChunkAt` | Focuses to an indexed entry of a `Chunk` | diff --git a/packages/effect-lens/package.json b/packages/effect-lens/package.json index 5b17c86..a58867e 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.1", + "version": "0.1.2", "type": "module", "files": [ "./README.md", -- 2.49.1 From b1277ebfb39744759299e417e2167a90ae6195f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 27 Mar 2026 14:30:57 +0100 Subject: [PATCH 8/8] Fix package.json --- packages/effect-lens/package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/effect-lens/package.json b/packages/effect-lens/package.json index a58867e..51e8dea 100644 --- a/packages/effect-lens/package.json +++ b/packages/effect-lens/package.json @@ -24,6 +24,10 @@ "./PropertyPath": { "types": "./dist/PropertyPath.d.ts", "default": "./dist/PropertyPath.js" + }, + "./Subscribable": { + "types": "./dist/Subscribable.d.ts", + "default": "./dist/Subscribable.js" } }, "scripts": { -- 2.49.1