Add Effect v4 version
Lint / lint (push) Successful in 15s

This commit is contained in:
Julien Valverdé
2026-06-20 16:29:44 +02:00
parent 8c946e5c4a
commit 68f293f4af
11 changed files with 2170 additions and 1 deletions
+45 -1
View File
@@ -21,6 +21,16 @@
"effect": "^3.21.0", "effect": "^3.21.0",
}, },
}, },
"packages/effect-lens-next": {
"name": "effect-lens-next",
"version": "0.1.0-beta.0",
"devDependencies": {
"effect": "4.0.0-beta.85",
},
"peerDependencies": {
"effect": "4.0.0-beta.85",
},
},
"packages/example": { "packages/example": {
"name": "@effect-lens/example", "name": "@effect-lens/example",
"version": "0.0.0", "version": "0.0.0",
@@ -140,10 +150,14 @@
"effect-lens": ["effect-lens@workspace:packages/effect-lens"], "effect-lens": ["effect-lens@workspace:packages/effect-lens"],
"effect-lens-next": ["effect-lens-next@workspace:packages/effect-lens-next"],
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
"ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
@@ -168,14 +182,44 @@
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="],
"turbo": ["turbo@2.9.16", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.16", "@turbo/darwin-arm64": "2.9.16", "@turbo/linux-64": "2.9.16", "@turbo/linux-arm64": "2.9.16", "@turbo/windows-64": "2.9.16", "@turbo/windows-arm64": "2.9.16" }, "bin": { "turbo": "bin/turbo" } }, "sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg=="], "turbo": ["turbo@2.9.16", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.16", "@turbo/darwin-arm64": "2.9.16", "@turbo/linux-64": "2.9.16", "@turbo/linux-arm64": "2.9.16", "@turbo/windows-64": "2.9.16", "@turbo/windows-arm64": "2.9.16" }, "bin": { "turbo": "bin/turbo" } }, "sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"@effect/experimental/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
"@effect/sql/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
"effect-lens-next/effect": ["effect@4.0.0-beta.85", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Cjv9YQyv4CiIccmRAQIWAoeESCpCpiuHYY8zb5vqiYs3Ac2yE5RQAnKq7z4Ir/3VJYZb8kxh6rS8czDDnpTdkQ=="],
"effect-lens-next/effect/fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
"effect-lens-next/effect/msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
"effect-lens-next/effect/fast-check/pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="],
"effect-lens-next/effect/msgpackr/msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
"effect-lens-next/effect/msgpackr/msgpackr-extract/@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
"effect-lens-next/effect/msgpackr/msgpackr-extract/@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
"effect-lens-next/effect/msgpackr/msgpackr-extract/@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="],
"effect-lens-next/effect/msgpackr/msgpackr-extract/@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="],
"effect-lens-next/effect/msgpackr/msgpackr-extract/@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="],
"effect-lens-next/effect/msgpackr/msgpackr-extract/@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
} }
} }
+137
View File
@@ -0,0 +1,137 @@
# Effect Lens Next
An effectful Lens for Effect v4.
`effect-lens-next` provides a bidirectional view into shared state. A Lens can read, observe, and atomically update a value while focusing on a nested part of a larger structure.
## Install
```sh
npm install effect@4.0.0-beta.85 effect-lens-next
```
This release targets Effect `4.0.0-beta.85` exactly while Effect v4 is in beta.
## Quickstart
```ts
import { Effect, Stream, SubscriptionRef } from "effect"
import { Lens } from "effect-lens-next"
const program = Effect.gen(function*() {
const state = yield* SubscriptionRef.make({ count: 0, label: "Counter" })
const count = state.pipe(
Lens.fromSubscriptionRef,
Lens.focusObjectOn("count")
)
yield* Lens.update(count, (value) => value + 1)
const current = yield* Lens.get(count)
yield* count.changes.pipe(
Stream.take(1),
Stream.runForEach((value) => Effect.log(`count: ${value}`))
)
return current
})
```
## Lens Type
```ts
Lens.Lens<
A, // focused value
ER, // read errors
EW, // write errors
RR, // read requirements
RW // write requirements
>
```
## Constructors
- `Lens.fromSubscriptionRef` creates a reactive Lens.
- `Lens.fromSynchronizedRef` creates a non-reactive Lens whose stream emits its value when evaluated.
- `Lens.fromRef` creates a non-reactive Lens and returns it in an Effect because it allocates a semaphore.
- `Lens.make` creates a Lens from custom `get`, `changes`, `commit`, and `lock` operations.
- `Lens.unwrap` flattens an effect that produces a Lens.
## Focusing
```ts
import { Effect, SubscriptionRef } from "effect"
import { Lens } from "effect-lens-next"
const program = Effect.gen(function*() {
const state = yield* SubscriptionRef.make({
users: [{ name: "Ada", score: 10 }]
})
const score = state.pipe(
Lens.fromSubscriptionRef,
Lens.focusObjectOn("users"),
Lens.focusArrayAt(0),
Lens.focusObjectOn("score")
)
yield* Lens.set(score, 11)
})
```
Available focus operations include object fields, mutable object fields, arrays, mutable arrays, tuples, mutable tuples, `Chunk` values, and `Option` values. Safe indexed operations fail with `Cause.NoSuchElementError` when the index is absent.
Effect v4 no longer treats `Option` as an Effect. Custom effectful mappings should convert optional values explicitly:
```ts
import { Array, Effect } from "effect"
Lens.mapEffect(
(values: ReadonlyArray<number>) => Effect.fromOption(Array.get(values, 0)),
(values, value) => Effect.fromOption(Array.replace(values, 0, value))
)
```
## Updating
The update API follows Effect v4's `SubscriptionRef` conventions:
- `get`, `set`, `getAndSet`, `setAndGet`
- `modify`, `modifyEffect`
- `update`, `updateEffect`
- `getAndUpdate`, `getAndUpdateEffect`
- `updateAndGet`, `updateAndGetEffect`
- `modifySome`, `modifySomeEffect`
- `getAndUpdateSome`, `getAndUpdateSomeEffect`
- `updateSome`, `updateSomeEffect`
- `updateSomeAndGet`, `updateSomeAndGetEffect`
Conditional updates use `Option.none()` to skip both the write and change notification:
```ts
import { Effect, Option } from "effect"
yield* Lens.updateSome(lens, (value) =>
value < 10 ? Option.some(value + 1) : Option.none()
)
yield* Lens.updateSomeEffect(lens, (value) =>
Effect.succeed(value < 10 ? Option.some(value + 1) : Option.none())
)
```
## Subscribable
Effect v4 removed its `Readable` and `Subscribable` modules. This package provides a small compatible abstraction with `get` and `changes` properties:
```ts
import { Subscribable } from "effect-lens-next"
const users = Subscribable.fromSubscriptionRef(usersRef)
const count = Subscribable.focusArrayLength(users)
const current = yield* count.get
```
The module includes `make`, `map`, `mapEffect`, `unwrap`, `fromSubscriptionRef`, and the same readonly focus helpers used by Lens.
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"root": false,
"extends": "//",
"files": {
"includes": ["./src/**"]
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"name": "effect-lens-next",
"description": "An effectful Lens type for Effect v4",
"version": "0.1.0-beta.0",
"type": "module",
"files": [
"./README.md",
"./dist"
],
"license": "MIT",
"repository": {
"url": "git+https://github.com/Thiladev/effect-lens.git"
},
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./Lens": {
"types": "./dist/Lens.d.ts",
"default": "./dist/Lens.js"
},
"./Subscribable": {
"types": "./dist/Subscribable.d.ts",
"default": "./dist/Subscribable.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"lint:tsc": "tsc --noEmit",
"lint:biome": "biome lint",
"pack": "npm pack",
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
"clean:dist": "rm -rf dist",
"clean:modules": "rm -rf node_modules"
},
"peerDependencies": {
"effect": "4.0.0-beta.85"
},
"devDependencies": {
"effect": "4.0.0-beta.85"
}
}
+565
View File
@@ -0,0 +1,565 @@
import { describe, expect, test } from "bun:test"
import { Chunk, Context, Effect, Fiber, identity, Option, Ref, Result, Stream, SubscriptionRef, SynchronizedRef } from "effect"
import * as Lens from "./Lens.js"
describe("Lens", () => {
class Offset extends Context.Service<Offset, { readonly value: number }>()("Offset") {}
test("mapErrorRead transforms read errors", async () => {
const lens = Lens.mapErrorRead(
Lens.make<number, "read", never, never, never>({
get: Effect.fail("read" as const),
changes: Stream.fail("read" as const),
commit: () => Effect.void,
lock: Effect.succeed(identity),
}),
error => `mapped:${ error }`,
)
const result = await Effect.runPromise(Effect.result(Lens.get(lens)))
expect(result).toEqual(Result.fail("mapped:read"))
})
test("mapErrorWrite transforms modify errors", async () => {
const lens = Lens.mapErrorWrite(
Lens.make<number, never, "write", never, never>({
get: Effect.succeed(1),
changes: Stream.make(1),
commit: () => Effect.fail("write" as const),
lock: Effect.succeed(identity),
}),
() => "mapped-write",
)
const result = await Effect.runPromise(Effect.result(Lens.set(lens, 2)))
expect(result).toEqual(Result.fail("mapped-write"))
})
test("mapError transforms read and modify errors", async () => {
const lens = Lens.mapError(
Lens.make<number, "read", "write", never, never>({
get: Effect.fail("read" as const),
changes: Stream.fail("read" as const),
commit: () => Effect.fail("write" as const),
lock: Effect.succeed(identity),
}),
() => "mapped",
)
const result = await Effect.runPromise(Effect.all([
Effect.result(Lens.get(lens)),
Effect.result(Lens.set(lens, 1)),
] as const))
expect(result[0]).toEqual(Result.fail("mapped"))
expect(result[1]).toEqual(Result.fail("mapped"))
})
test("tapErrorRead runs an effect on read failures", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make(0),
counter => {
const lens = Lens.tapErrorRead(
Lens.make<number, "read", never, never, never>({
get: Effect.fail("read" as const),
changes: Stream.fail("read" as const),
commit: () => Effect.void,
lock: Effect.succeed(identity),
}),
() => SubscriptionRef.modify(counter, n => [void 0, n + 1] as const),
)
return Effect.flatMap(
Effect.result(Lens.get(lens)),
() => SubscriptionRef.get(counter),
)
},
),
)
expect(result).toBe(1)
})
test("tapErrorWrite runs an effect on modify failures", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make(0),
counter => {
const lens = Lens.tapErrorWrite(
Lens.make<number, never, "write", never, never>({
get: Effect.succeed(1),
changes: Stream.make(1),
commit: () => Effect.fail("write" as const),
lock: Effect.succeed(identity),
}),
() => SubscriptionRef.modify(counter, n => [void 0, n + 1] as const),
)
return Effect.flatMap(
Effect.result(Lens.set(lens, 2)),
() => SubscriptionRef.get(counter),
)
},
),
)
expect(result).toBe(1)
})
test("mapOption transforms Some values and preserves None", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make<Option.Option<number>>(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(SubscriptionRef.get(parent), 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.Option<number>>(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(SubscriptionRef.get(parent), parentValue => [value, parentValue] as const),
),
)
},
),
)
expect(result[0]).toEqual(Option.some(84)) // 42 * 2
expect(result[1]).toEqual(Option.some(50)) // 100 / 2
})
test("provideContext supplies a service to get and modify", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make(10),
parent => {
const lens = Lens.provideContext(
Lens.mapEffect(
Lens.fromSubscriptionRef(parent),
n => Effect.map(Offset, ({ value }) => n + value),
(_n, next) => Effect.map(Offset, ({ value }) => next - value),
),
Context.make(Offset, { value: 5 }),
)
return Effect.flatMap(
Lens.get(lens),
value => Effect.flatMap(
Lens.set(lens, 30),
() => Effect.map(SubscriptionRef.get(parent), parentValue => [value, parentValue] as const),
),
)
},
),
)
expect(result[0]).toBe(15)
expect(result[1]).toBe(25)
})
test("Ref and SynchronizedRef adapters read and update their sources", async () => {
const result = await Effect.runPromise(Effect.gen(function*() {
const ref = yield* Ref.make(1)
const refLens = yield* Lens.fromRef(ref)
yield* Lens.update(refLens, n => n + 1)
const synchronizedRef = yield* SynchronizedRef.make(10)
const synchronizedLens = Lens.fromSynchronizedRef(synchronizedRef)
yield* Lens.updateEffect(synchronizedLens, n => Effect.succeed(n + 5))
return [yield* Ref.get(ref), yield* SynchronizedRef.get(synchronizedRef)] as const
}))
expect(result).toEqual([2, 15])
})
test("modifyEffect updates are atomic under concurrency", async () => {
const iterations = 100
const result = await Effect.runPromise(Effect.flatMap(
SubscriptionRef.make({ count: 0 }),
parent => {
const countLens = Lens.focusObjectOn(Lens.fromSubscriptionRef(parent), "count")
return Effect.flatMap(
Effect.forEach(
Array.from({ length: iterations }),
() => Lens.updateEffect(
countLens,
count => Effect.as(Effect.yieldNow, count + 1),
),
{ concurrency: "unbounded", discard: true },
),
() => SubscriptionRef.get(parent),
)
},
))
expect(result.count).toBe(iterations)
})
test("unwrap delegates reads, writes, and locking to the inner lens", async () => {
const iterations = 100
const result = await Effect.runPromise(Effect.flatMap(
SubscriptionRef.make(0),
parent => {
const lens = Lens.unwrap(Effect.succeed(Lens.fromSubscriptionRef(parent)))
return Effect.flatMap(
Effect.forEach(
Array.from({ length: iterations }),
() => Lens.updateEffect(
lens,
count => Effect.as(Effect.yieldNow, count + 1),
),
{ concurrency: "unbounded", discard: true },
),
() => Effect.all([Lens.get(lens), SubscriptionRef.get(parent)] as const),
)
},
))
expect(result).toEqual([iterations, iterations])
})
test("focusObjectOn 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.focusObjectOn(Lens.fromSubscriptionRef(parent), "count")
return Effect.flatMap(
Lens.get(countLens),
count => Effect.flatMap(
Lens.set(countLens, count + 5),
() => Effect.map(SubscriptionRef.get(parent), state => [count, state] as const),
),
)
},
),
)
expect(initialCount).toBe(1)
expect(updatedState).toEqual({ count: 6, label: "original" })
})
test("focusObjectOnWritable 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.focusObjectOnWritable(Lens.fromSubscriptionRef(parent), "detail")
return Effect.flatMap(
Lens.set(detailLens, "mutated"),
() => SubscriptionRef.get(parent),
)
},
),
)
expect(updated).toBe(original)
expect(updated.detail).toBe("mutated")
})
test("focusArrayAt updates the selected index", async () => {
const updated = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make([10, 20, 30]),
parent => {
const elementLens = Lens.focusArrayAt(Lens.fromSubscriptionRef(parent), 1)
return Effect.flatMap(
Lens.update(elementLens, value => value + 5),
() => SubscriptionRef.get(parent),
)
},
),
)
expect(updated).toEqual([10, 25, 30])
})
test("focusMutableArrayAt mutates the array reference in place", async () => {
const original = ["foo", "bar"]
const updated = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make(original),
parent => {
const elementLens = Lens.focusMutableArrayAt(Lens.fromSubscriptionRef(parent), 0)
return Effect.flatMap(
Lens.set(elementLens, "baz"),
() => SubscriptionRef.get(parent),
)
},
),
)
expect(updated).toBe(original)
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"),
() => SubscriptionRef.get(parent),
)
},
),
)
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"),
() => SubscriptionRef.get(parent),
)
},
),
)
expect(updated).toBe(original)
expect(updated).toEqual(["baz", "bar"])
})
test("focusChunkAt replaces the focused chunk element", async () => {
const updated = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make(Chunk.make(1, 2, 3) as Chunk.Chunk<number>),
parent => {
const elementLens = Lens.focusChunkAt(Lens.fromSubscriptionRef(parent), 2)
return Effect.flatMap(
Lens.set(elementLens, 99),
() => SubscriptionRef.get(parent),
)
},
),
)
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(SubscriptionRef.get(parent), 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.result(Lens.get(lens)),
Effect.result(Lens.set(lens, 100)),
SubscriptionRef.get(parent),
] as const)
},
),
)
expect(result[0]._tag).toBe("Failure")
expect(result[1]._tag).toBe("Failure")
expect(result[2]).toEqual(Option.none())
})
test("modify and modifyEffect atomically update and return a result", async () => {
const result = await Effect.runPromise(Effect.gen(function*() {
const parent = yield* SubscriptionRef.make(1)
const lens = Lens.fromSubscriptionRef(parent)
const previous = yield* Lens.modify(lens, n => [`value:${ n }`, n + 1] as const)
const doubled = yield* lens.pipe(Lens.modifyEffect(n => Effect.succeed([n * 2, n + 2] as const)))
const current = yield* SubscriptionRef.get(parent)
return [previous, doubled, current] as const
}))
expect(result).toEqual(["value:1", 4, 4])
})
test("conditional synchronous operations preserve values on None", async () => {
const result = await Effect.runPromise(Effect.gen(function*() {
const parent = yield* SubscriptionRef.make(1)
const lens = Lens.fromSubscriptionRef(parent)
const fallback = yield* Lens.modifySome(lens, () => ["fallback", Option.none()] as const)
const modified = yield* lens.pipe(Lens.modifySome(n => {
n satisfies number
return [`value:${ n }`, Option.some(n + 1)] as const
}))
const previous = yield* lens.pipe(Lens.getAndUpdateSome(n => {
n satisfies number
return Option.some(n + 1)
}))
yield* Lens.updateSome(lens, () => Option.none())
yield* lens.pipe(Lens.updateSome(n => {
n satisfies number
return Option.some(n + 1)
}))
const current = yield* Lens.updateSomeAndGet(lens, () => Option.none())
const updated = yield* lens.pipe(Lens.updateSomeAndGet(n => {
n satisfies number
return Option.some(n + 1)
}))
return [fallback, modified, previous, current, updated, yield* SubscriptionRef.get(parent)] as const
}))
expect(result).toEqual(["fallback", "value:1", 2, 4, 5, 5])
})
test("conditional effectful operations preserve values on None", async () => {
const result = await Effect.runPromise(Effect.gen(function*() {
const parent = yield* SubscriptionRef.make(10)
const lens = Lens.fromSubscriptionRef(parent)
const modifyNone: Effect.Effect<string> = Lens.modifySomeEffect(
lens,
() => Effect.succeed(["fallback", Option.none()] as const),
)
const fallback = yield* modifyNone
const modify: Effect.Effect<string> = lens.pipe(Lens.modifySomeEffect(
n => {
n satisfies number
return Effect.succeed([`value:${ n }`, Option.some(n + 1)] as const)
},
))
const modified = yield* modify
const getAndUpdate: Effect.Effect<number> = lens.pipe(Lens.getAndUpdateSomeEffect(n => {
n satisfies number
return Effect.succeed(Option.some(n + 1))
}))
const getAndUpdateNone: Effect.Effect<number> = lens.pipe(Lens.getAndUpdateSomeEffect(() => Effect.succeed(Option.none())))
const updateNone: Effect.Effect<void> = lens.pipe(Lens.updateSomeEffect(() => Effect.succeed(Option.none())))
const update: Effect.Effect<void> = lens.pipe(Lens.updateSomeEffect(n => {
n satisfies number
return Effect.succeed(Option.some(n + 1))
}))
const previous = yield* getAndUpdate
const unchanged = yield* getAndUpdateNone
yield* updateNone
yield* update
const updateAndGetNone: Effect.Effect<number> = lens.pipe(Lens.updateSomeAndGetEffect(() => Effect.succeed(Option.none())))
const current = yield* updateAndGetNone
const updated = yield* lens.pipe(Lens.updateSomeAndGetEffect(n => {
n satisfies number
return Effect.succeed(Option.some(n + 1))
}))
return [fallback, modified, previous, unchanged, current, updated, yield* SubscriptionRef.get(parent)] as const
}))
expect(result).toEqual(["fallback", "value:10", 11, 12, 13, 14, 14])
})
test("conditional updates do not publish when the next value is None", async () => {
const events = await Effect.runPromise(Effect.gen(function*() {
const parent = yield* SubscriptionRef.make(0)
const lens = Lens.fromSubscriptionRef(parent)
const fiber = yield* Effect.forkChild(Stream.runCollect(Stream.take(lens.changes, 2)))
yield* Effect.yieldNow
yield* Lens.updateSome(lens, () => Option.none())
yield* Lens.updateSome(lens, n => Option.some(n + 1))
return yield* Fiber.join(fiber)
}))
expect(events).toEqual([0, 1])
})
// test("changes stream emits updates when lens mutates state", async () => {
// const events = await Effect.runPromise(
// Effect.flatMap(
// SubscriptionRef.make({ count: 0 }),
// parent => {
// const lens = Lens.mapField(Lens.fromSubscriptionRef(parent), "count")
// return Effect.fork(Stream.runCollect(Stream.take(lens.changes, 2))).pipe(
// Effect.tap(Lens.set(lens, 1)),
// Effect.tap(Lens.set(lens, 1)),
// Effect.andThen(Fiber.join),
// Effect.map(Chunk.toReadonlyArray),
// )
// },
// ),
// )
// expect(events).toEqual([1, 2])
// })
// test("mapped changes stream can derive transformed values", async () => {
// const derived = await Effect.runPromise(
// Effect.flatMap(
// SubscriptionRef.make({ count: 10 }),
// parent => {
// const lens = Lens.mapField(Lens.fromSubscriptionRef(parent), "count")
// const transformed = Stream.map(lens.changes, count => `count:${ count }`)
// return Effect.scoped(() => Effect.flatMap(
// Effect.forkScoped(Stream.runCollect(Stream.take(transformed, 1))),
// fiber => Effect.flatMap(
// Lens.set(lens, 42),
// () => Effect.join(fiber),
// ),
// ))
// },
// ),
// )
// expect(derived).toEqual(["count:42"])
// })
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test"
import { Chunk, Effect, SubscriptionRef } from "effect"
import * as Subscribable from "./Subscribable.js"
describe("Subscribable", () => {
test("focusArrayLength reads the current array length and reflects updates", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make([1, 2, 3]),
parent => {
const sizeSub = Subscribable.focusArrayLength(Subscribable.fromSubscriptionRef(parent))
return Effect.flatMap(
sizeSub.get,
initial => Effect.flatMap(
SubscriptionRef.set(parent, [1, 2, 3, 4, 5]),
() => Effect.map(sizeSub.get, next => [initial, next] as const),
),
)
},
),
)
expect(result).toEqual([3, 5])
})
test("focusChunkSize reads the current chunk size and reflects updates", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make(Chunk.make(1, 2) as Chunk.Chunk<number>),
parent => {
const sizeSub = Subscribable.focusChunkSize(Subscribable.fromSubscriptionRef(parent))
return Effect.flatMap(
sizeSub.get,
initial => Effect.flatMap(
SubscriptionRef.set(parent, Chunk.make(1, 2, 3, 4)),
() => Effect.map(sizeSub.get, next => [initial, next] as const),
),
)
},
),
)
expect(result).toEqual([2, 4])
})
test("focusIterableSize also works for array values", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make([1, 2, 3]),
parent => {
const sizeSub = Subscribable.focusIterableSize(Subscribable.fromSubscriptionRef(parent))
return Effect.flatMap(
sizeSub.get,
initial => Effect.flatMap(
SubscriptionRef.set(parent, [1, 2, 3, 4, 5]),
() => Effect.map(sizeSub.get, next => [initial, next] as const),
),
)
},
),
)
expect(result).toEqual([3, 5])
})
})
@@ -0,0 +1,137 @@
import {
Array,
type Cause,
Chunk,
Effect,
Function,
Iterable,
Option,
Pipeable,
Predicate,
Stream,
SubscriptionRef,
} from "effect"
export const TypeId: unique symbol = Symbol.for("@effect-fc/Lens/v4/Subscribable")
export type TypeId = typeof TypeId
export interface Subscribable<in out A, in out E = never, in out R = never> extends Pipeable.Pipeable {
readonly [TypeId]: TypeId
readonly get: Effect.Effect<A, E, R>
readonly changes: Stream.Stream<A, E, R>
}
class SubscribableImpl<in out A, in out E, in out R>
extends Pipeable.Class implements Subscribable<A, E, R> {
readonly [TypeId]: TypeId = TypeId
constructor(
readonly get: Effect.Effect<A, E, R>,
readonly changes: Stream.Stream<A, E, R>,
) {
super()
}
}
export const isSubscribable = (u: unknown): u is Subscribable<unknown, unknown, unknown> => Predicate.hasProperty(u, TypeId)
export const make = <A, E, R>(options: {
readonly get: Effect.Effect<A, E, R>
readonly changes: Stream.Stream<A, E, R>
}): Subscribable<A, E, R> => new SubscribableImpl(options.get, options.changes)
export const fromSubscriptionRef = <A>(self: SubscriptionRef.SubscriptionRef<A>): Subscribable<A> => make({
get: SubscriptionRef.get(self),
changes: SubscriptionRef.changes(self),
})
export const map: {
<A, B>(f: (a: NoInfer<A>) => B): <E, R>(self: Subscribable<A, E, R>) => Subscribable<B, E, R>
<A, E, R, B>(self: Subscribable<A, E, R>, f: (a: NoInfer<A>) => B): Subscribable<B, E, R>
} = Function.dual(2, <A, E, R, B>(self: Subscribable<A, E, R>, f: (a: NoInfer<A>) => B) => make({
get: Effect.map(self.get, f),
changes: Stream.map(self.changes, f),
}))
export const mapEffect: {
<A, B, E2, R2>(f: (a: NoInfer<A>) => Effect.Effect<B, E2, R2>): <E, R>(self: Subscribable<A, E, R>) => Subscribable<B, E | E2, R | R2>
<A, E, R, B, E2, R2>(self: Subscribable<A, E, R>, f: (a: NoInfer<A>) => Effect.Effect<B, E2, R2>): Subscribable<B, E | E2, R | R2>
} = Function.dual(2, <A, E, R, B, E2, R2>(
self: Subscribable<A, E, R>,
f: (a: NoInfer<A>) => Effect.Effect<B, E2, R2>,
) => make({
get: Effect.flatMap(self.get, f),
changes: Stream.mapEffect(self.changes, f),
}))
export const unwrap = <A, E, R, E1, R1>(
effect: Effect.Effect<Subscribable<A, E, R>, E1, R1>,
): Subscribable<A, E | E1, R | R1> => make({
get: Effect.flatMap(effect, self => self.get),
changes: Stream.unwrap(Effect.map(effect, self => self.changes)),
})
/** Maps over an `Option` value in the `Subscribable`. */
export const mapOption: {
<A, B>(f: (a: A) => B): <E, R>(self: Subscribable<Option.Option<A>, E, R>) => Subscribable<Option.Option<B>, E, R>
<A, B, E, R>(self: Subscribable<Option.Option<A>, E, R>, f: (a: A) => B): Subscribable<Option.Option<B>, E, R>
} = Function.dual(2, <A, B, E, R>(self: Subscribable<Option.Option<A>, E, R>, f: (a: A) => B) =>
map(self, Option.map(f)),
)
/** Maps over an `Option` value in the `Subscribable` with an Effect. */
export const mapOptionEffect: {
<A, B, E2, R2>(f: (a: A) => Effect.Effect<B, E2, R2>): <E, R>(self: Subscribable<Option.Option<A>, E, R>) => Subscribable<Option.Option<B>, E | E2, R | R2>
<A, B, E, R, E2, R2>(self: Subscribable<Option.Option<A>, E, R>, f: (a: A) => Effect.Effect<B, E2, R2>): Subscribable<Option.Option<B>, E | E2, R | R2>
} = Function.dual(2, <A, B, E, R, E2, R2>(
self: Subscribable<Option.Option<A>, E, R>,
f: (a: A) => Effect.Effect<B, E2, R2>,
) => mapEffect(self, Option.match({
onSome: a => Effect.map(f(a), Option.some),
onNone: () => Effect.succeed(Option.none()),
})))
/** Narrows the focus to a field of an object. */
export const focusObjectOn: {
<A extends object, K extends keyof A>(key: K): <E, R>(self: Subscribable<A, E, R>) => Subscribable<A[K], E, R>
<A extends object, K extends keyof A, E, R>(self: Subscribable<A, E, R>, key: K): Subscribable<A[K], E, R>
} = Function.dual(2, <A extends object, K extends keyof A, E, R>(self: Subscribable<A, E, R>, key: K) =>
map(self, a => a[key]),
)
/** Narrows the focus to an indexed element of an array. */
export const focusArrayAt: {
<A extends readonly any[]>(index: number): <E, R>(self: Subscribable<A, E, R>) => Subscribable<A[number], E | Cause.NoSuchElementError, R>
<A extends readonly any[], E, R>(self: Subscribable<A, E, R>, index: number): Subscribable<A[number], E | Cause.NoSuchElementError, R>
} = Function.dual(2, <A extends readonly any[], E, R>(self: Subscribable<A, E, R>, index: number) =>
mapEffect(self, a => Effect.fromOption(Array.get(a, index))),
)
export const focusArrayLength = <A extends readonly any[], E, R>(
self: Subscribable<A, E, R>,
): Subscribable<number, E, R> => map(self, Array.length)
/** Narrows the focus to an indexed element of a readonly tuple. */
export const focusTupleAt: {
<T extends readonly [any, ...any[]], I extends number>(index: I): <E, R>(self: Subscribable<T, E, R>) => Subscribable<T[I], E, R>
<T extends readonly [any, ...any[]], I extends number, E, R>(self: Subscribable<T, E, R>, index: I): Subscribable<T[I], E, R>
} = Function.dual(2, <T extends readonly [any, ...any[]], I extends number, E, R>(self: Subscribable<T, E, R>, index: I) =>
map(self, Array.getUnsafe(index)),
)
/** Narrows the focus to an indexed element of `Chunk`. */
export const focusChunkAt: {
<A>(index: number): <E, R>(self: Subscribable<Chunk.Chunk<A>, E, R>) => Subscribable<A, E | Cause.NoSuchElementError, R>
<A, E, R>(self: Subscribable<Chunk.Chunk<A>, E, R>, index: number): Subscribable<A, E | Cause.NoSuchElementError, R>
} = Function.dual(2, <A, E, R>(self: Subscribable<Chunk.Chunk<A>, E, R>, index: number) =>
mapEffect(self, chunk => Effect.fromOption(Chunk.get(chunk, index))),
)
export const focusChunkSize = <A, E, R>(
self: Subscribable<Chunk.Chunk<A>, E, R>,
): Subscribable<number, E, R> => map(self, Chunk.size)
export const focusIterableSize = <A extends Iterable<any>, E, R>(
self: Subscribable<A, E, R>,
): Subscribable<number, E, R> => map(self, Iterable.size)
+2
View File
@@ -0,0 +1,2 @@
export * as Lens from "./Lens.js"
export * as Subscribable from "./Subscribable.js"
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
}
+39
View File
@@ -0,0 +1,39 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "NodeNext",
"moduleDetection": "force",
"types": ["bun"],
"jsx": "react-jsx",
// Bundler mode
"moduleResolution": "NodeNext",
// "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
// "noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
// Build
"rootDir": "./src",
"outDir": "./dist",
"declaration": true,
"sourceMap": true,
"plugins": [
{ "name": "@effect/language-service" }
]
},
"include": ["./src"],
}