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.
mapasTick 0. Input: a; Map: f(a); 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.
filterTick 0. Input: 1.
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.
filterMapTick 0. Input: 1.
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.
compactTick 0. Input: Some(a); Output: a.
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:
mapBothTick 0. Input: ok; Output: OK.
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.
mapEffectTick 0. Input: 1; Output: label-1.
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.
filterEffectTick 0. Input: 1.
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.
filterMapEffectTick 0. Input: 1.
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.
tapTick 0. Input: 1; Output: 1.
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.