From 1a5b26549a18fa47bf1462aba3aaa1d7477b3ed0 Mon Sep 17 00:00:00 2001 From: Samuel Laycock Date: Tue, 19 May 2026 20:57:50 +0100 Subject: [PATCH 1/2] feat: add timeout option helper Expose fx.timeoutOption as a timeout-as-value helper over Effect.timeoutOption. Add runtime coverage for fast and slow task behavior, type-level coverage for result/error/dependency inference, and document when to prefer the helper over fx.timeout or fx.timeoutFail. --- .changeset/timeout-option-helper.md | 5 +++++ docs/api-reference.md | 1 + docs/retry-timeout.md | 15 +++++++++++++++ src/concurrency.ts | 4 ++++ src/index.ts | 1 + test/runtime.test.ts | 14 +++++++++++++- test/types.test.ts | 17 ++++++++++++++++- 7 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 .changeset/timeout-option-helper.md diff --git a/.changeset/timeout-option-helper.md b/.changeset/timeout-option-helper.md new file mode 100644 index 0000000..6ca774f --- /dev/null +++ b/.changeset/timeout-option-helper.md @@ -0,0 +1,5 @@ +--- +"fluent-effect": minor +--- + +Add `fx.timeoutOption` for timeout-as-value workflows. diff --git a/docs/api-reference.md b/docs/api-reference.md index 41cfa69..65a0f2e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -66,6 +66,7 @@ import { fx } from "fluent-effect"; | `fx.retryBackoff` | Retry with exponential backoff. | | `fx.timeout` | Apply a timeout, optionally converting timeout to a typed failure. | | `fx.timeoutFail` | Apply a timeout that fails with an application error. | +| `fx.timeoutOption` | Apply a timeout that returns `Option.none()` when it wins. | | `fx.log`, `fx.logWarn`, `fx.logError` | Log through Effect with optional structured metadata. | | `fx.trace`, `fx.span` | Wrap a task in an Effect tracing span with optional span metadata. | diff --git a/docs/retry-timeout.md b/docs/retry-timeout.md index 0c68bb3..9cbdb17 100644 --- a/docs/retry-timeout.md +++ b/docs/retry-timeout.md @@ -80,3 +80,18 @@ fx.timeoutFail(task, "5 seconds", () => AppError.Timeout({ operation })); Prefer the typed failure form when a timeout should be handled like other application errors. + +Use `fx.timeoutOption(task, duration)` when crossing a boundary where timeout is +an expected value-level outcome. It delegates to `Effect.timeoutOption`, returns +`Option.some(value)` when the task completes in time, and returns `Option.none()` +when the timeout wins. The original task errors and dependency requirements are +preserved. + +```ts +const maybeUser = fx.timeoutOption(loadUser, "500 millis"); +``` + +Prefer `fx.timeoutOption` for optional/result-style boundary workflows, prefer +`fx.timeoutFail` when callers should handle timeout as a typed application +error, and keep `fx.timeout` when you explicitly want native Effect timeout +semantics. diff --git a/src/concurrency.ts b/src/concurrency.ts index 31dc7f3..1199bdc 100644 --- a/src/concurrency.ts +++ b/src/concurrency.ts @@ -289,3 +289,7 @@ export const timeoutFail = ( duration: Duration.DurationInput, onTimeout: () => E1, ): Task => timeout(self, duration, onTimeout); + +/** Helper: add a timeout that returns None when the timeout wins. */ +export const timeoutOption = (self: Task, duration: Duration.DurationInput) => + Effect.timeoutOption(self, duration); diff --git a/src/index.ts b/src/index.ts index 62ddba6..86bcb6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,6 +78,7 @@ export const fx = { retryBackoff: concurrency.retryBackoff, timeout: concurrency.timeout, timeoutFail: concurrency.timeoutFail, + timeoutOption: concurrency.timeoutOption, // Error handling recover: errors.recover, diff --git a/test/runtime.test.ts b/test/runtime.test.ts index afb72cb..43c65ba 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { Deferred, Effect, Fiber, Layer, Ref } from "../src/effect"; +import { Deferred, Effect, Fiber, Layer, Option, Ref } from "../src/effect"; import { fx, type ErrorsOf, type Task } from "../src/index"; const makeInterruptibleTask = ( @@ -1422,6 +1422,18 @@ describe("fx runtime behavior", () => { expect(result).toBe("fast"); }); + test("fx.timeoutOption returns None for slow tasks", async () => { + const result = await fx.run(fx.timeoutOption(Effect.sleep("20 millis"), "1 millis")); + + expect(Option.isNone(result)).toBe(true); + }); + + test("fx.timeoutOption returns Some value for fast tasks", async () => { + const result = await fx.run(fx.timeoutOption(fx.succeed("fast"), "1 second")); + + expect(result).toEqual(Option.some("fast")); + }); + test("fx.retry with backoff options retries until success", async () => { let attempts = 0; diff --git a/test/types.test.ts b/test/types.test.ts index b09203f..7dadc3c 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -1,4 +1,4 @@ -import { Effect, Either, Layer, Scope } from "../src/effect"; +import { Effect, Either, Layer, Option, Scope } from "../src/effect"; import { fx, type ErrorOf, @@ -347,6 +347,8 @@ const timeoutFailedOverload = fx.timeout( "1 second", () => new TimedOut(), ); +const timeoutOptioned = fx.timeoutOption(fx.ok(user) as Task, "1 second"); +const timeoutOptionedWithDependency = fx.timeoutOption(getUser, "1 second"); type _timeout_fail_result = Expect, User>>; type _timeout_fail_error = Expect, NetworkError | TimedOut>>; @@ -354,6 +356,19 @@ type _timeout_fail_overload_result = Expect, NetworkError | TimedOut> >; +type _timeout_option_result = Expect< + Equal, Option.Option> +>; +type _timeout_option_error = Expect, NetworkError>>; +type _timeout_option_dependency_result = Expect< + Equal, Option.Option> +>; +type _timeout_option_dependency_error = Expect< + Equal, NotFound> +>; +type _timeout_option_dependency_deps = Expect< + Equal, UserRepo> +>; const spanned = fx.span(fx.ok(user), "load-user", { attributes: { userId: user.id } }); const traced = fx.trace(fx.ok(user), "load-user", { attributes: { userId: user.id } }); From 4a0681cc755e81aeb05e34da9dec6bdcd39819ab Mon Sep 17 00:00:00 2001 From: Samuel Laycock Date: Tue, 19 May 2026 21:17:48 +0100 Subject: [PATCH 2/2] fix: declare timeout option return type Import Option and annotate fx.timeoutOption with its public Task, E, R> contract. This matches the exported helper style used throughout the concurrency module and addresses the PR review feedback. --- src/concurrency.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/concurrency.ts b/src/concurrency.ts index 1199bdc..c09f7b1 100644 --- a/src/concurrency.ts +++ b/src/concurrency.ts @@ -1,4 +1,4 @@ -import { Duration, Effect, Schedule } from "effect"; +import { Duration, Effect, Option, Schedule } from "effect"; import type { Task } from "./types.js"; @@ -291,5 +291,7 @@ export const timeoutFail = ( ): Task => timeout(self, duration, onTimeout); /** Helper: add a timeout that returns None when the timeout wins. */ -export const timeoutOption = (self: Task, duration: Duration.DurationInput) => - Effect.timeoutOption(self, duration); +export const timeoutOption = ( + self: Task, + duration: Duration.DurationInput, +): Task, E, R> => Effect.timeoutOption(self, duration);