Add error handling primitives to Subscribable
Lint / lint (push) Successful in 15s

This commit is contained in:
Julien Valverdé
2026-06-21 21:34:43 +02:00
parent a1fbd8a670
commit 778a041a13
6 changed files with 135 additions and 5 deletions
+1 -1
View File
@@ -134,4 +134,4 @@ const count = Subscribable.focusArrayLength(users)
const current = yield* count.get const current = yield* count.get
``` ```
The module includes `make`, `map`, `mapEffect`, `unwrap`, and the same readonly focus helpers used by Lens. A `SubscriptionRef` can first be converted with `Lens.fromSubscriptionRef`, since every Lens is already a Subscribable. The module includes `make`, `map`, `mapEffect`, `mapError`, `tapError`, `unwrap`, and the same readonly focus helpers used by Lens. A `SubscriptionRef` can first be converted with `Lens.fromSubscriptionRef`, since every Lens is already a Subscribable.
@@ -1,10 +1,42 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Chunk, Effect, SubscriptionRef } from "effect" import { Chunk, Effect, Stream, SubscriptionRef } from "effect"
import * as Lens from "./Lens.js" import * as Lens from "./Lens.js"
import * as Subscribable from "./Subscribable.js" import * as Subscribable from "./Subscribable.js"
describe("Subscribable", () => { describe("Subscribable", () => {
test("mapError transforms errors from get and changes", async () => {
const source = Subscribable.make({
get: Effect.fail("get"),
changes: Stream.fail("changes"),
})
const mapped = source.pipe(Subscribable.mapError((error: string) => `mapped:${error}`))
const result = await Effect.runPromise(Effect.gen(function*() {
const getError = yield* Effect.flip(mapped.get)
const changesError = yield* Effect.flip(Stream.runDrain(mapped.changes))
return [getError, changesError]
}))
expect(result).toEqual(["mapped:get", "mapped:changes"])
})
test("tapError observes errors from get and changes", async () => {
const observed: Array<string> = []
const source = Subscribable.make({
get: Effect.fail("get"),
changes: Stream.fail("changes"),
})
const tapped = Subscribable.tapError(source, error => Effect.sync(() => observed.push(error)))
await Effect.runPromise(Effect.gen(function*() {
yield* Effect.flip(tapped.get)
yield* Effect.flip(Stream.runDrain(tapped.changes))
}))
expect(observed).toEqual(["get", "changes"])
})
test("focusArrayLength reads the current array length and reflects updates", async () => { test("focusArrayLength reads the current array length and reflects updates", async () => {
const result = await Effect.runPromise( const result = await Effect.runPromise(
Effect.flatMap( Effect.flatMap(
@@ -100,6 +100,31 @@ export const mapOptionEffect: {
}))) })))
/** Maps errors from both the current value and the stream of changes. */
export const mapError: {
<E, E2>(f: (error: NoInfer<E>) => E2): <A, R>(self: Subscribable<A, E, R>) => Subscribable<A, E2, R>
<A, E, R, E2>(self: Subscribable<A, E, R>, f: (error: NoInfer<E>) => E2): Subscribable<A, E2, R>
} = Function.dual(2, <A, E, R, E2>(
self: Subscribable<A, E, R>,
f: (error: NoInfer<E>) => E2,
) => make({
get get() { return Effect.mapError(self.get, f) },
get changes() { return Stream.mapError(self.changes, f) },
}))
/** Runs an effect when either the current value or the stream of changes fails. */
export const tapError: {
<E, B, E2, R2>(f: (error: NoInfer<E>) => Effect.Effect<B, E2, R2>): <A, R>(self: Subscribable<A, E, R>) => Subscribable<A, E | E2, R | R2>
<A, E, R, B, E2, R2>(self: Subscribable<A, E, R>, f: (error: NoInfer<E>) => Effect.Effect<B, E2, R2>): Subscribable<A, E | E2, R | R2>
} = Function.dual(2, <A, E, R, B, E2, R2>(
self: Subscribable<A, E, R>,
f: (error: NoInfer<E>) => Effect.Effect<B, E2, R2>,
) => make({
get get() { return Effect.tapError(self.get, f) },
get changes() { return Stream.tapError(self.changes, f) },
}))
/** Narrows the focus to a field of an object. */ /** Narrows the focus to a field of an object. */
export const focusObjectOn: { 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>(key: K): <E, R>(self: Subscribable<A, E, R>) => Subscribable<A[K], E, R>
+1 -1
View File
@@ -263,7 +263,7 @@ yield* someFunctionThatShouldOnlyHaveReadonlyAccessToTheState(lens)
``` ```
#### Focusing #### Focusing
This library re-exports Effect's `Subscribable` module and adds a few transforms to narrow the focus of `Subscribable`'s, same as Lenses: This library re-exports Effect's `Subscribable` module and adds `mapError`, `tapError`, and a few transforms to narrow the focus of `Subscribable`'s, same as Lenses:
```typescript ```typescript
import { Subscribable } from "effect-lens" import { Subscribable } from "effect-lens"
+33 -1
View File
@@ -1,9 +1,41 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Chunk, Effect, SubscriptionRef } from "effect" import { Chunk, Effect, Stream, SubscriptionRef } from "effect"
import * as Subscribable from "./Subscribable.js" import * as Subscribable from "./Subscribable.js"
describe("Subscribable", () => { describe("Subscribable", () => {
test("mapError transforms errors from get and changes", async () => {
const source = Subscribable.make({
get: Effect.fail("get"),
changes: Stream.fail("changes"),
})
const mapped = source.pipe(Subscribable.mapError((error: string) => `mapped:${error}`))
const result = await Effect.runPromise(Effect.gen(function*() {
const getError = yield* Effect.flip(mapped.get)
const changesError = yield* Effect.flip(Stream.runDrain(mapped.changes))
return [getError, changesError]
}))
expect(result).toEqual(["mapped:get", "mapped:changes"])
})
test("tapError observes errors from get and changes", async () => {
const observed: Array<string> = []
const source = Subscribable.make({
get: Effect.fail("get"),
changes: Stream.fail("changes"),
})
const tapped = Subscribable.tapError(source, error => Effect.sync(() => observed.push(error)))
await Effect.runPromise(Effect.gen(function*() {
yield* Effect.flip(tapped.get)
yield* Effect.flip(Stream.runDrain(tapped.changes))
}))
expect(observed).toEqual(["get", "changes"])
})
test("focusArrayLength reads the current array length and reflects updates", async () => { test("focusArrayLength reads the current array length and reflects updates", async () => {
const result = await Effect.runPromise( const result = await Effect.runPromise(
Effect.flatMap( Effect.flatMap(
+42 -1
View File
@@ -1,9 +1,10 @@
import { Array, Chunk, Effect, Function, Iterable, Option, Subscribable } from "effect" import { Array, Chunk, Effect, Function, Iterable, Option, Stream, Subscribable } from "effect"
import type { NoSuchElementException } from "effect/Cause" import type { NoSuchElementException } from "effect/Cause"
export * from "effect/Subscribable" export * from "effect/Subscribable"
/** /**
* Maps over an `Option` value in the `Subscribable`. * Maps over an `Option` value in the `Subscribable`.
*/ */
@@ -39,6 +40,46 @@ export const mapOptionEffect: {
onNone: () => Effect.succeed(Option.none()), onNone: () => Effect.succeed(Option.none()),
}))) })))
/**
* Maps errors from both the current value and the stream of changes.
*/
export const mapError: {
<E, E2>(
f: (error: NoInfer<E>) => E2,
): <A, R>(self: Subscribable.Subscribable<A, E, R>) => Subscribable.Subscribable<A, E2, R>
<A, E, R, E2>(
self: Subscribable.Subscribable<A, E, R>,
f: (error: NoInfer<E>) => E2,
): Subscribable.Subscribable<A, E2, R>
} = Function.dual(2, <A, E, R, E2>(
self: Subscribable.Subscribable<A, E, R>,
f: (error: NoInfer<E>) => E2,
): Subscribable.Subscribable<A, E2, R> => Subscribable.make({
get: Effect.mapError(self.get, f),
changes: Stream.mapError(self.changes, f),
}))
/**
* Runs an effect when either the current value or the stream of changes fails.
*/
export const tapError: {
<E, B, E2, R2>(
f: (error: NoInfer<E>) => Effect.Effect<B, E2, R2>,
): <A, R>(self: Subscribable.Subscribable<A, E, R>) => Subscribable.Subscribable<A, E | E2, R | R2>
<A, E, R, B, E2, R2>(
self: Subscribable.Subscribable<A, E, R>,
f: (error: NoInfer<E>) => Effect.Effect<B, E2, R2>,
): Subscribable.Subscribable<A, E | E2, R | R2>
} = Function.dual(2, <A, E, R, B, E2, R2>(
self: Subscribable.Subscribable<A, E, R>,
f: (error: NoInfer<E>) => Effect.Effect<B, E2, R2>,
): Subscribable.Subscribable<A, E | E2, R | R2> => Subscribable.make({
get: Effect.tapError(self.get, f),
changes: Stream.tapError(self.changes, f),
}))
/** /**
* Narrows the focus to a field of an object. * Narrows the focus to a field of an object.
*/ */