0.1.2 #1

Merged
Thilawyn merged 8 commits from next into master 2026-03-27 14:32:05 +01:00
6 changed files with 193 additions and 25 deletions

View File

@@ -2,8 +2,6 @@
A Lens type for [Effect](https://effect.website/) to easily manage nested state. 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 ## Install
``` ```
npm install effect-lens npm install effect-lens
@@ -140,7 +138,7 @@ const ref = yield* SubscriptionRef.make<{
// \/ Lens<User, NoSuchElementException, NoSuchElementException, never, never> // \/ Lens<User, NoSuchElementException, NoSuchElementException, never, never>
const jeanDupontLens = ref.pipe( const jeanDupontLens = ref.pipe(
Lens.fromSubscriptionRef, // Creates a lens that proxies the ref 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 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 // Reading or writing from this lense can fail with NoSuchElementException
@@ -150,7 +148,7 @@ const jeanDupont = yield* Lens.get(jeanDupontLens)
yield* Lens.set( yield* Lens.set(
// You can focus even further down // You can focus even further down
Lens.focusField(jeanDupontLens, "age"), Lens.focusObjectField(jeanDupontLens, "age"),
yield* DateTime.make("03/25/1970"), yield* DateTime.make("03/25/1970"),
) )
// Mutations with the parent state are performed immutably by default // Mutations with the parent state are performed immutably by default
@@ -160,10 +158,12 @@ yield* Lens.set(
Currently available: Currently available:
| Name | Description | Parent state mutation behavior | Notes | | 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 | | | `focusObjectField` | 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 | | `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 | | | `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 | | `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 | | | `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! Also more to come!
@@ -171,11 +171,6 @@ Also more to come!
#### Manually #### Manually
You can create focused Lenses by composing them manually using `map`, `mapEffect` and `unwrap`: You can create focused Lenses by composing them manually using `map`, `mapEffect` and `unwrap`:
```typescript ```typescript
interface User {
readonly name: string
readonly age: DateTime.Utc
}
const ref = yield* SubscriptionRef.make<readonly User[]>([ const ref = yield* SubscriptionRef.make<readonly User[]>([
{ name: "Jean Dupont", age: yield* DateTime.make("03/25/1969") }, { name: "Jean Dupont", age: yield* DateTime.make("03/25/1969") },
{ name: "Juan Joya Borja", age: yield* DateTime.make("04/05/1956") }, { name: "Juan Joya Borja", age: yield* DateTime.make("04/05/1956") },
@@ -221,11 +216,33 @@ const someFunctionThatShouldOnlyHaveReadonlyAccessToTheState = (
const lens = ref.pipe( const lens = ref.pipe(
Lens.fromSubscriptionRef, Lens.fromSubscriptionRef,
Lens.focusField("users"), Lens.focusObjectField("users"),
) )
yield* someFunctionThatShouldOnlyHaveReadonlyAccessToTheState(lens) 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<readonly { name: string }[], never, never>
// \/ Subscribabe.Subscribable<string, NoSuchElementException, never>
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 |
| `focusTupleAt` | Focuses to an indexed entry of a tuple |
| `focusChunkAt` | Focuses to an indexed entry of a `Chunk` |
## Todo ## Todo

View File

@@ -1,7 +1,7 @@
{ {
"name": "effect-lens", "name": "effect-lens",
"description": "An effectful Lens type to easily manage nested state", "description": "An effectful Lens type to easily manage nested state",
"version": "0.1.1", "version": "0.1.2",
"type": "module", "type": "module",
"files": [ "files": [
"./README.md", "./README.md",
@@ -24,6 +24,10 @@
"./PropertyPath": { "./PropertyPath": {
"types": "./dist/PropertyPath.d.ts", "types": "./dist/PropertyPath.d.ts",
"default": "./dist/PropertyPath.js" "default": "./dist/PropertyPath.js"
},
"./Subscribable": {
"types": "./dist/Subscribable.d.ts",
"default": "./dist/Subscribable.js"
} }
}, },
"scripts": { "scripts": {

View File

@@ -4,12 +4,12 @@ import * as Lens from "./Lens.js"
describe("Lens", () => { 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( const [initialCount, updatedState] = await Effect.runPromise(
Effect.flatMap( Effect.flatMap(
SubscriptionRef.make({ count: 1, label: "original" }), SubscriptionRef.make({ count: 1, label: "original" }),
parent => { parent => {
const countLens = Lens.focusField(Lens.fromSubscriptionRef(parent), "count") const countLens = Lens.focusObjectField(Lens.fromSubscriptionRef(parent), "count")
return Effect.flatMap( return Effect.flatMap(
Lens.get(countLens), Lens.get(countLens),
count => Effect.flatMap( count => Effect.flatMap(
@@ -25,13 +25,13 @@ describe("Lens", () => {
expect(updatedState).toEqual({ count: 6, label: "original" }) 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 original = { detail: "keep" }
const updated = await Effect.runPromise( const updated = await Effect.runPromise(
Effect.flatMap( Effect.flatMap(
SubscriptionRef.make(original), SubscriptionRef.make(original),
parent => { parent => {
const detailLens = Lens.focusMutableField(Lens.fromSubscriptionRef(parent), "detail") const detailLens = Lens.focusObjectMutableField(Lens.fromSubscriptionRef(parent), "detail")
return Effect.flatMap( return Effect.flatMap(
Lens.set(detailLens, "mutated"), Lens.set(detailLens, "mutated"),
() => parent.get, () => parent.get,
@@ -80,6 +80,42 @@ describe("Lens", () => {
expect(updated).toEqual(["baz", "bar"]) expect(updated).toEqual(["baz", "bar"])
}) })
test("focusTupleAt updates the selected tuple index immutably", async () => {
const updated = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make<readonly [string, string, string]>(["a", "b", "c"]),
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: [string, string] = ["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 () => { test("focusChunkAt replaces the focused chunk element", async () => {
const updated = await Effect.runPromise( const updated = await Effect.runPromise(
Effect.flatMap( Effect.flatMap(

View File

@@ -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 type { NoSuchElementException } from "effect/Cause"
import * as Subscribable from "./Subscribable.js"
export const LensTypeId: unique symbol = Symbol.for("@effect-fc/Lens/Lens") 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. * 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: {
<A extends object, K extends keyof A, ER, EW, RR, RW>( <A extends object, K extends keyof A, ER, EW, RR, RW>(
self: Lens<A, ER, EW, RR, RW>, self: Lens<A, ER, EW, RR, RW>,
key: K, key: K,
@@ -223,7 +224,7 @@ export const focusField: {
(a, b) => Object.setPrototypeOf({ ...a, [key]: b }, Object.getPrototypeOf(a)), (a, b) => Object.setPrototypeOf({ ...a, [key]: b }, Object.getPrototypeOf(a)),
)) ))
export declare namespace focusMutableField { export declare namespace focusObjectMutableField {
export type WritableKeys<T> = { export type WritableKeys<T> = {
[K in keyof T]-?: IfEquals< [K in keyof T]-?: IfEquals<
{ [P in K]: T[K] }, { [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. * Narrows the focus to a mutable field of an object. Mutates the object in place when written to.
*/ */
export const focusMutableField: { export const focusObjectMutableField: {
<A extends object, K extends focusMutableField.WritableKeys<A>, ER, EW, RR, RW>( <A extends object, K extends focusObjectMutableField.WritableKeys<A>, ER, EW, RR, RW>(
self: Lens<A, ER, EW, RR, RW>, self: Lens<A, ER, EW, RR, RW>,
key: K, key: K,
): Lens<A[K], ER, EW, RR, RW> ): Lens<A[K], ER, EW, RR, RW>
<A extends object, K extends focusMutableField.WritableKeys<A>, ER, EW, RR, RW>( <A extends object, K extends focusObjectMutableField.WritableKeys<A>, ER, EW, RR, RW>(
key: K, key: K,
): (self: Lens<A, ER, EW, RR, RW>) => Lens<A[K], ER, EW, RR, RW> ): (self: Lens<A, ER, EW, RR, RW>) => Lens<A[K], ER, EW, RR, RW>
} = Function.dual(2, <A extends object, K extends focusMutableField.WritableKeys<A>, ER, EW, RR, RW>( } = Function.dual(2, <A extends object, K extends focusObjectMutableField.WritableKeys<A>, ER, EW, RR, RW>(
self: Lens<A, ER, EW, RR, RW>, self: Lens<A, ER, EW, RR, RW>,
key: K, key: K,
): Lens<A[K], ER, EW, RR, RW> => map(self, a => a[key], (a, b) => { a[key] = b; return a })) ): Lens<A[K], ER, EW, RR, RW> => map(self, a => a[key], (a, b) => { a[key] = b; return a }))
@@ -259,7 +260,7 @@ export const focusArrayAt: {
<A extends readonly any[], ER, EW, RR, RW>( <A extends readonly any[], ER, EW, RR, RW>(
self: Lens<A, ER, EW, RR, RW>, self: Lens<A, ER, EW, RR, RW>,
index: number, index: number,
): Lens<A[number]> ): Lens<A[number], ER | NoSuchElementException, EW | NoSuchElementException, RR, RW>
<A extends readonly any[], ER, EW, RR, RW>( <A extends readonly any[], ER, EW, RR, RW>(
index: number index: number
): (self: Lens<A, ER, EW, RR, RW>) => Lens<A[number], ER | NoSuchElementException, EW | NoSuchElementException, RR, RW> ): (self: Lens<A, ER, EW, RR, RW>) => Lens<A[number], ER | NoSuchElementException, EW | NoSuchElementException, RR, RW>
@@ -295,6 +296,46 @@ 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: {
<T extends readonly [any, ...any[]], I extends number, ER, EW, RR, RW>(
self: Lens<T, ER, EW, RR, RW>,
index: I,
): Lens<T[I], ER, EW, RR, RW>
<T extends readonly [any, ...any[]], I extends number, ER, EW, RR, RW>(
index: I
): (self: Lens<T, ER, EW, RR, RW>) => Lens<T[I], ER, EW, RR, RW>
} = Function.dual(2, <T extends readonly [any, ...any[]], I extends number, ER, EW, RR, RW>(
self: Lens<T, ER, EW, RR, RW>,
index: I,
): Lens<T[I], ER, EW, RR, RW> => map(
self,
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: {
<T extends [any, ...any[]], I extends number, ER, EW, RR, RW>(
self: Lens<T, ER, EW, RR, RW>,
index: I,
): Lens<T[I], ER, EW, RR, RW>
<T extends [any, ...any[]], I extends number, ER, EW, RR, RW>(
index: I
): (self: Lens<T, ER, EW, RR, RW>) => Lens<T[I], ER, EW, RR, RW>
} = Function.dual(2, <T extends [any, ...any[]], I extends number, ER, EW, RR, RW>(
self: Lens<T, ER, EW, RR, RW>,
index: I,
): Lens<T[I], ER, EW, RR, RW> => map(
self,
Array.unsafeGet(index),
(a, b) => { a[index] = b; return a },
))
/** /**
* Narrows the focus to an indexed element of `Chunk`. Replaces the `Chunk` in an immutable fashion when written to. * Narrows the focus to an indexed element of `Chunk`. Replaces the `Chunk` in an immutable fashion when written to.
*/ */

View File

@@ -0,0 +1,69 @@
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: {
<A extends object, K extends keyof A, E, R>(
self: Subscribable.Subscribable<A, E, R>,
key: K,
): Subscribable.Subscribable<A[K], E, R>
<A extends object, K extends keyof A, E, R>(
key: K,
): (self: Subscribable.Subscribable<A, E, R>) => Subscribable.Subscribable<A[K], E, R>
} = Function.dual(2, <A extends object, K extends keyof A, E, R>(
self: Subscribable.Subscribable<A, E, R>,
key: K,
): Subscribable.Subscribable<A[K], E, R> => Subscribable.map(self, a => a[key]))
/**
* Narrows the focus to an indexed element of an array.
*/
export const focusArrayAt: {
<A extends readonly any[], E, R>(
self: Subscribable.Subscribable<A, E, R>,
index: number,
): Subscribable.Subscribable<A[number], E, R>
<A extends readonly any[], E, R>(
index: number
): (self: Subscribable.Subscribable<A, E, R>) => Subscribable.Subscribable<A[number], E | NoSuchElementException, R>
} = Function.dual(2, <A extends readonly any[], E, R>(
self: Subscribable.Subscribable<A, E, R>,
index: number,
): Subscribable.Subscribable<A[number], E | NoSuchElementException, R> => Subscribable.mapEffect(self, Array.get(index)))
/**
* Narrows the focus to an indexed element of a readonly tuple.
*/
export const focusTupleAt: {
<T extends readonly [any, ...any[]], I extends number, E, R>(
self: Subscribable.Subscribable<T, E, R>,
index: I,
): Subscribable.Subscribable<T[I], E, R>
<T extends readonly [any, ...any[]], I extends number, E, R>(
index: I
): (self: Subscribable.Subscribable<T, E, R>) => Subscribable.Subscribable<T[I], E, R>
} = Function.dual(2, <T extends readonly [any, ...any[]], I extends number, E, R>(
self: Subscribable.Subscribable<T, E, R>,
index: I,
): Subscribable.Subscribable<T[I], E, R> => Subscribable.map(self, Array.unsafeGet(index)))
/**
* Narrows the focus to an indexed element of `Chunk`.
*/
export const focusChunkAt: {
<A, E, R>(
self: Subscribable.Subscribable<Chunk.Chunk<A>, E, R>,
index: number,
): Subscribable.Subscribable<A, E | NoSuchElementException, R>
<A, E, R>(
index: number
): (self: Subscribable.Subscribable<Chunk.Chunk<A>, E, R>) => Subscribable.Subscribable<A, E | NoSuchElementException, R>
} = Function.dual(2, <A, E, R>(
self: Subscribable.Subscribable<Chunk.Chunk<A>, E, R>,
index: number,
): Subscribable.Subscribable<A, E | NoSuchElementException, R> => Subscribable.mapEffect(self, Chunk.get(index)))

View File

@@ -1,2 +1,3 @@
export * as Lens from "./Lens.js" export * as Lens from "./Lens.js"
export * as PropertyPath from "./PropertyPath.js" export * as PropertyPath from "./PropertyPath.js"
export * as Subscribable from "./Subscribable.js"