Browse documentation

Fx

Choose an Fx producer dynamically

Build a workspace feed whose configuration, connection, and cleanup are resolved when it is observed.

A workspace activity feed can start in two modes. Offline workspaces show a cached snapshot and finish. Connected workspaces acquire a connection and keep receiving events. The workspace is an argument, but configuration and connection acquisition belong to the subscription: calling a function should not open a socket that nobody observes.

Building Fx values introduced individual sources. This lesson puts a setup phase in front of a source, then gives setup resources the same lifetime as their selected producer. The key distinction is between choosing an Fx and emitting a value from that Fx.

First, choose a producer with an Effect

Suppose the configuration decision is already an Effect. Its result can be the producer itself:

import { Effect } from "effect";
import { Fx } from "@typed/fx";

const chooseActivity = Effect.succeed(
  Fx.fromIterable(["workspace:opened", "workspace:updated"]),
);

const activity = Fx.unwrap(chooseActivity);
const result = await Effect.runPromise(Fx.collectAll(activity));
// ["workspace:opened", "workspace:updated"]

Fx.unwrap does not emit the Fx object. When activity is observed, it runs chooseActivity, subscribes to the returned Fx, and forwards that producer’s events. A failed choice starts no producer. Interrupting the observer interrupts whichever phase is active.

Fx timelinesetup chooses a producer before any selected event can arrivegenunwrap
Operatorunwrap(setup) / gen(setup)
Read this diagram

Follow each lane from left to right. Events stacked vertically share a tick; the green cursor marks the current time across every lane.

  • A value
    The text inside the pill is the emitted value.
  • Work starts
    The raised chevron starts an inner run (^ in the source).
  • The run returns
    The vertical bar ends this lane’s run.
  • A cause is delivered
    The exclamation mark belongs to this lane.
  • Work is interrupted
    The cross marks cancellation of this run.
  • Current time
    The line and diamond move together across all lanes.
  • Happening now
    A highlighted event is at the current tick.
  • Still ahead
    Muted, dashed values have not happened yet.
  • Time continues
    The lane’s arrow is not a return marker. An empty stretch can be quiet work that is still running.

Illustrated ticks start at 0. At 1×, one illustrated tick takes one second; captions specify real durations when timing matters. A cause or interruption belongs to its lane, and other work may continue. Scroll horizontally to inspect the rest of a long timeline.

Read down from choose: it permits the selected lane’s raised start chevron, not an output event. The first output is opened. The spaces between phases are logical sequencing, not promised clock delays.

Fx.gen is the generator form of this same two-phase operation. Yield setup Effects and return the selected Fx. Use it for one producer value; the workspace feed needs parameters, so it uses Fx.fn.

Make the workspace an argument and configuration a requirement

Fx.fn preserves the body’s parameters and combines requirements from its yielded Effects and returned Fx. This complete example uses a finite fake connection so its output and cleanup can be inspected without a running server. A real adapter can supply a live callback-backed events Fx through the same contract.

import { Context, Data, Effect } from "effect";
import { Fx } from "@typed/fx";

class ConnectionRejected extends Data.TaggedError("ConnectionRejected")<{
  readonly workspace: string;
}> {}

class ActivitySource extends Context.Service<ActivitySource, {
  readonly mode: (workspace: string) => Effect.Effect<"cached" | "connected">;
  readonly open: (workspace: string) => Effect.Effect<{
    readonly events: Fx.Fx<string>;
    readonly close: Effect.Effect<void>;
  }, ConnectionRejected>;
}>()("example/ActivitySource") {}

const activityFor = Fx.fn(function* (workspace: string) {
  const source = yield* ActivitySource;
  const mode = yield* source.mode(workspace);

  if (mode === "cached") return Fx.succeed(`${workspace}:cached`);

  return Fx.genScoped(function* () {
    const connection = yield* Effect.acquireRelease(
      source.open(workspace),
      (connection) => connection.close,
    );
    return connection.events;
  });
});

const designActivity: Fx.Fx<string, ConnectionRejected, ActivitySource> = activityFor("design");

const program = Fx.collectAll(designActivity).pipe(
  Effect.provideService(ActivitySource, {
    mode: () => Effect.succeed("connected" as const),
    open: (workspace) => Effect.succeed({
      events: Fx.fromIterable([`${workspace}:opened`, `${workspace}:updated`]),
      close: Effect.log(`closed ${workspace}`),
    }),
  }),
);

const result = await Effect.runPromise(program);
// Logs "closed design" and returns ["design:opened", "design:updated"].

Calling activityFor("design") captures the argument, but does not read configuration. Running program reads the provided service, asks for the mode, acquires the connection, and observes its events. Completion closes the connection before the result returns. A second observation repeats all those steps; this is a producer factory, not a connection cache.

The annotation is the public contract: values are strings, acquisition may fail with ConnectionRejected, and an ActivitySource must be provided. Scope does not escape because genScoped owns the connection. Replacing the live service with a fake changes the destination and timing, not those type channels.

Keep acquisition alive through the selected producer

If open were scoped and completed before returning connection.events, the connection would already be closed when its producer started. The scope must enclose both setup and observation. genScoped does that for the connected branch above. When acquisition already exists as an Effect returning Fx, use unwrapScoped for the same lifetime rule:

import { Effect } from "effect";
import { Fx } from "@typed/fx";

const acquireActivity = Effect.gen(function* () {
  const workspace = yield* Effect.acquireRelease(
    Effect.succeed("design"),
    () => Effect.log("released design connection"),
  );
  return Fx.fromIterable([`${workspace}:opened`, `${workspace}:updated`]);
});

const activity = Fx.unwrapScoped(acquireActivity);
const firstEvent = Fx.first(activity);
Fx timelineunwrapScoped holds the acquired resource until selected observation endsunwrapScoped
OperatorunwrapScoped(setup)
Read this diagram

Follow each lane from left to right. Events stacked vertically share a tick; the green cursor marks the current time across every lane.

  • A value
    The text inside the pill is the emitted value.
  • Work starts
    The raised chevron starts an inner run (^ in the source).
  • The run returns
    The vertical bar ends this lane’s run.
  • A cause is delivered
    The exclamation mark belongs to this lane.
  • Work is interrupted
    The cross marks cancellation of this run.
  • Current time
    The line and diamond move together across all lanes.
  • Happening now
    A highlighted event is at the current tick.
  • Still ahead
    Muted, dashed values have not happened yet.
  • Time continues
    The lane’s arrow is not a return marker. An empty stretch can be quiet work that is still running.

Illustrated ticks start at 0. At 1×, one illustrated tick takes one second; captions specify real durations when timing matters. A cause or interruption belongs to its lane, and other work may continue. Scroll horizontally to inspect the rest of a long timeline.

The resource lane extends beyond setup’s completion. firstEvent can stop earlier than the depicted full run: after opened, it interrupts the selected producer and releases the same resource. No special “first event” cleanup path is needed. Plain unwrap and gen preserve a setup Scope requirement for the caller; their scoped counterparts own it internally.

Decide what a mode change means

This feed reads mode once per subscription. It does not automatically switch when configuration changes later. If mode itself is a live producer, use switchMap to select a new scoped feed on each mode change. The old branch is interrupted and finalized before the replacement starts. If several consumers should use the same connection, apply an explicit sharing policy after constructing the feed.

For a failed feed, locate the phase that failed: no acquisition means configuration failed or chose cache; acquisition without events suggests the selected source is silent; release before events means scope placement is wrong. Test both mode branches, rejected acquisition, and interruption of a silent connection. Then continue with Transforming Fx to turn the selected events into useful values without repeating setup.