Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/timeout-option-helper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"fluent-effect": minor
---

Add `fx.timeoutOption` for timeout-as-value workflows.
1 change: 1 addition & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
15 changes: 15 additions & 0 deletions docs/retry-timeout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 7 additions & 1 deletion src/concurrency.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Duration, Effect, Schedule } from "effect";
import { Duration, Effect, Option, Schedule } from "effect";

import type { Task } from "./types.js";

Expand Down Expand Up @@ -289,3 +289,9 @@ export const timeoutFail = <A, E, R, E1>(
duration: Duration.DurationInput,
onTimeout: () => E1,
): Task<A, E | E1, R> => timeout(self, duration, onTimeout);

/** Helper: add a timeout that returns None when the timeout wins. */
export const timeoutOption = <A, E, R>(
self: Task<A, E, R>,
duration: Duration.DurationInput,
): Task<Option.Option<A>, E, R> => Effect.timeoutOption(self, duration);
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export const fx = {
retryBackoff: concurrency.retryBackoff,
timeout: concurrency.timeout,
timeoutFail: concurrency.timeoutFail,
timeoutOption: concurrency.timeoutOption,

// Error handling
recover: errors.recover,
Expand Down
14 changes: 13 additions & 1 deletion test/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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 = (
Expand Down Expand Up @@ -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;

Expand Down
17 changes: 16 additions & 1 deletion test/types.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -347,13 +347,28 @@ const timeoutFailedOverload = fx.timeout(
"1 second",
() => new TimedOut(),
);
const timeoutOptioned = fx.timeoutOption(fx.ok(user) as Task<User, NetworkError>, "1 second");
const timeoutOptionedWithDependency = fx.timeoutOption(getUser, "1 second");

type _timeout_fail_result = Expect<Equal<TaskResult<typeof timeoutFailed>, User>>;
type _timeout_fail_error = Expect<Equal<TaskError<typeof timeoutFailed>, NetworkError | TimedOut>>;
type _timeout_fail_overload_result = Expect<Equal<TaskResult<typeof timeoutFailedOverload>, User>>;
type _timeout_fail_overload_error = Expect<
Equal<TaskError<typeof timeoutFailedOverload>, NetworkError | TimedOut>
>;
type _timeout_option_result = Expect<
Equal<TaskResult<typeof timeoutOptioned>, Option.Option<User>>
>;
type _timeout_option_error = Expect<Equal<TaskError<typeof timeoutOptioned>, NetworkError>>;
type _timeout_option_dependency_result = Expect<
Equal<TaskResult<typeof timeoutOptionedWithDependency>, Option.Option<User>>
>;
type _timeout_option_dependency_error = Expect<
Equal<TaskError<typeof timeoutOptionedWithDependency>, NotFound>
>;
type _timeout_option_dependency_deps = Expect<
Equal<TaskDeps<typeof timeoutOptionedWithDependency>, 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 } });
Expand Down
Loading