Browse documentation

Fx

Transforming Fx

Turn pushed values into useful domain data without hiding failures, services, or timing.

A catalog feed contains records the page cannot display directly: inactive products, raw cents, and prices that need a currency service. The source already decides when records arrive. This lesson turns each record into useful page data without changing who owns the source.

Start with Building Fx. We first make decisions from one input alone, then introduce a service, and finally add just enough history and time for repeated user input.

Admit a product and build its display value

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

interface Product {
  readonly id: string;
  readonly name: string;
  readonly priceInCents: number;
  readonly active: boolean;
}

const products = Fx.fromIterable<Product>([
  { id: "desk", name: "Standing desk", priceInCents: 49900, active: true },
  { id: "lamp", name: "Desk lamp", priceInCents: 8900, active: false },
]);

const cards = products.pipe(
  Fx.filterMap((product) => (product.active ? Option.some(product) : Option.none())),
  Fx.map(({ id, name, priceInCents }) => ({
    id,
    title: name,
    price: `$${(priceInCents / 100).toFixed(2)}`,
  })),
);

const result = await Effect.runPromise(Fx.collectAll(cards));
// [{ id: "desk", title: "Standing desk", price: "$499.00" }]

The active desk becomes a card; the inactive lamp produces no output. filterMap combines admission and transformation through Option: Some emits, None omits. Use filter when the original value should remain unchanged and map when every input always has one output. as replaces each value with a constant; compact unwraps a producer that already emits Options.

Fx timelinemap and as emit once for every inputmapas
Operatormap(f) / as(value)
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 vertically: the map output depends on its input; as has the same value in every occupied slot. Neither removes events.

Fx timelinefilter keeps admitted values in their input slotsfilter
Operatorfilter(isEven)
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.

Fx timelinefilterMap omits None and emits each Some in orderfilterMap
OperatorfilterMap(toOption)
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.

Fx timelinecompact drops None and unwraps Somecompact
Operatorcompact
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 empty output slots are omissions, not work delayed until later. In the catalog pipeline, the lamp is rejected before formatting. Swapping a filter with expensive formatting changes which work runs even when the displayed cards happen to match.

mapBoth additionally translates the expected failure channel while mapping successful records. It does not recover the source or restart its work:

Fx timelinemapBoth keeps one success output while also mapping typed failuresmapBoth
OperatormapBoth({ onSuccess, onFailure })
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.

Here ok becomes OK, and a later offline failure becomes OfflineError. A thrown decoder error inside map is a defect, not a typed parse result. Move expected failure into Effect rather than using a pure callback as an untracked request or exception boundary.

Introduce the currency service where it is needed

Converting prices requires a rate and can fail when a currency is unsupported. The Effect callback makes those requirements visible on the output Fx:

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

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

class ExchangeRates extends Context.Service<
  ExchangeRates,
  {
    readonly fromUsd: (currency: string) => Effect.Effect<number, MissingRate>;
  }
>()("docs/ExchangeRates") {}

interface Price {
  readonly usd: number;
  readonly currency: string;
}

const prices = Fx.fromIterable<Price>([
  { usd: 499, currency: "EUR" },
  { usd: 89, currency: "GBP" },
]);

const convertPrice = Effect.fn("convertPrice")(function* (price: Price) {
  const rates = yield* ExchangeRates;
  const rate = yield* rates.fromUsd(price.currency);
  return price.usd * rate;
});

const converted: Fx.Fx<number, MissingRate, ExchangeRates> = prices.pipe(
  Fx.mapEffect(convertPrice),
);

const runnable = converted.pipe(
  Fx.provideService(ExchangeRates, {
    fromUsd: (currency) =>
      currency === "EUR"
        ? Effect.succeed(0.92)
        : currency === "GBP"
          ? Effect.succeed(0.79)
          : Effect.fail(new MissingRate({ currency })),
  }),
);

const result = await Effect.runPromise(Fx.collectAll(runnable));
// [459.08, 70.31]

mapEffect combines the source and callback error/service channels. converted therefore requires ExchangeRates and can report MissingRate. Providing the service chooses the application’s rates; it does not silently catch missing ones.

Fx timelinemapEffect emits one successful result for each inputmapEffect
OperatormapEffect(loadLabel)
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.

Fx timelinefilterEffect keeps values whose Effectful predicate succeedsfilterEffect
OperatorfilterEffect(isEven)
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.

Fx timelinefilterMapEffect emits only successful Some resultsfilterMapEffect
OperatorfilterMapEffect(parse)
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.

Fx timelinetap observes each value before forwarding ittap
Operatortap(record)
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.

These rows assume sequential delivery. mapEffect forwards the callback’s result; filterEffect forwards the original value only for true; filterMapEffect forwards only Some; tap forwards the original after its observation Effect. A failed predicate is not false: it enters the failure channel. Recovery decides whether that stops the feature.

Effectful transformation inherits producer concurrency. If two callback deliveries overlap, the second lookup may finish first. No queue is added here. Choose an explicit higher-order policy when the requirement is “finish every conversion in order” or “discard obsolete work.”

Normalize before comparing repeated input

A search field demonstrates why operator order is product behavior:

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

const queries = Fx.fromIterable([" t", "ty", "typed", "typed "]).pipe(
  Fx.map((query) => query.trim()),
  Fx.filter((query) => query.length >= 2),
  Fx.skipRepeats,
  Fx.debounce("10 millis"),
);

const result = await Effect.runPromise(Effect.scoped(Fx.collectAll(queries)));
// ["typed"]

Trim first so "typed" and "typed " become the same query. Reject short queries before they reach the request boundary. skipRepeats remembers the last emitted value for this run; debounce then waits for quiet. Reversing normalization and comparison can launch a duplicate request for a whitespace-only edit. Reversing tap and the filter similarly changes whether a metric counts raw keystrokes or accepted queries.

A second observation gets fresh comparison state and a fresh timer. This pipeline has not created shared writable state. Continue with stateful transforms for accumulators and transitions, time and rate for clock tests, or Composing Fx to combine the normalized query with a category filter.