Skip to content

What a text diff misses

Every section on this page is generated from real TypeScript files in apps/docs/samples/review-scenarios by running effect-analyzer against them. No hand-written output — if the analyzer changes, rerun pnpm --dir apps/docs run generate:review-scenarios and this page will update.

The setup: the transfer team is iterating on a send-money workflow. Here are five pull requests from a single sprint. Each one has a plausible motivation. Each one hides a change that a line-by-line diff does not communicate.

Two new lines. A whole new failure mode. Is there a test?

The team is building up the transfer workflow. This PR adds an exchange-rate lookup after input validation.

It looks like a small addition — import a dep, call it, return the extra field.

--- before.ts
+++ after.ts
@@ -1,19 +1,29 @@
-// PR #1 — before: validation only
+// PR #1 — after: now fetches exchange rate
import { Effect } from 'effect';
type TransferInput = { amount: number; from: string; to: string };
type ValidatedTransfer = TransferInput & { validatedAt: Date };
type ValidationError = { _tag: 'ValidationError'; reason: string };
+type ExchangeRate = { rate: number };
+type RateUnavailableError = { _tag: 'RateUnavailableError' };
type Deps = {
readonly validateTransfer: (
input: TransferInput,
) => Effect.Effect<ValidatedTransfer, ValidationError>;
+ readonly fetchRate: (args: {
+ from: string;
+ to: string;
+ }) => Effect.Effect<ExchangeRate, RateUnavailableError>;
};
export const createSendMoneyWorkflow =
(deps: Deps) => (input: TransferInput) =>
Effect.gen(function* () {
const validated = yield* deps.validateTransfer(input);
- return { validated };
+ const rate = yield* deps.fetchRate({
+ from: validated.from,
+ to: validated.to,
+ });
+ return { validated, rate };
});

The structural diff names the new step. More importantly, the workflow’s error channel just doubled: ValidationErrorValidationError | RateUnavailableError. Every call site of this workflow is now a new potential failure surface.

Structural diff:

Effect Program Diff: createSendMoneyWorkflow → createSendMoneyWorkflow

Section titled “Effect Program Diff: createSendMoneyWorkflow → createSendMoneyWorkflow”
MetricCount
Added1
Removed0
Renamed0
Moved0
Unchanged1
Structural changes0
**deps.fetchRate** (added, id: `effect-5`)

Before → After railway:

flowchart LR
  A["validated #lt;- deps.validateTransfer"] -->|ok| Done((Success))
  A -->|err| AE["Validation"]
flowchart LR
  A["validated #lt;- deps.validateTransfer"] -->|ok| B["rate #lt;- deps.fetchRate"]
  B -->|ok| Done((Success))
  A -->|err| AE["Validation"]
  B -->|err| BE["RateUnavailable"]

Declared error union: + RateUnavailableError

Effect count: 1 → 2

Is there a test for RateUnavailableError? In production, rate providers have outages. A sequential retry would have been a different PR. Silently propagating is a choice — not necessarily the wrong one, but a choice the team should make deliberately.

Raw analyzer explain (before / after)
createSendMoneyWorkflow (generator):
1. Yields validated <- deps.validateTransfer
Error paths: ValidationError
Concurrency: sequential (no parallelism)
createSendMoneyWorkflow (generator):
1. Yields validated <- deps.validateTransfer
2. Yields rate <- deps.fetchRate
Error paths: RateUnavailableError, ValidationError
Concurrency: sequential (no parallelism)

A performance refactor that changes failure semantics.

Rate fetch and balance lookup were sequential. The PR parallelizes them with Effect.all({ concurrency: "unbounded" }).

Looks like a clean refactor — two yield* lines collapsed into one destructured Effect.all.

--- before.ts
+++ after.ts
@@ -1,4 +1,4 @@
-// PR #2 — before: sequential rate + balance lookup
+// PR #2 — after: parallelized for speed
import { Effect } from 'effect';
type Currency = 'USD' | 'EUR' | 'GBP';
@@ -15,7 +15,9 @@
export const prepareTransfer = (deps: Deps) => (from: Currency, to: Currency) =>
Effect.gen(function* () {
- const rate = yield* deps.fetchRate({ from, to });
- const balance = yield* deps.getBalance();
+ const [rate, balance] = yield* Effect.all(
+ [deps.fetchRate({ from, to }), deps.getBalance()],
+ { concurrency: 'unbounded' },
+ );
return { rate, balance };
});

The structural diff flags + parallel block added. The railway diagram restructures. When two operations run concurrently, a failure in one interrupts the other — that is not what the sequential code did. If fetchRate is observably slow or has side effects (telemetry, rate-limiter entries), the semantics have changed.

Structural diff:

Effect Program Diff: prepareTransfer → prepareTransfer

Section titled “Effect Program Diff: prepareTransfer → prepareTransfer”
MetricCount
Added0
Removed0
Renamed2
Moved0
Unchanged0
Structural changes1
~ **deps.fetchRate** (renamed from `effect-1` → `effect-5`)
~ **deps.getBalance** (renamed from `effect-2` → `effect-6`)
    • parallel block added

Before → After railway:

flowchart LR
  A["rate #lt;- deps.fetchRate"] -->|ok| B["balance #lt;- deps.getBalance"]
  B -->|ok| Done((Success))
  A -->|err| AE["RateUnavailable"]
flowchart LR
  A["Effect.all #lpar;2#rpar;"] -->|ok| Done((Success))
  A -->|err| AE["RateUnavailable"]

Declared error union: declared error union unchanged (only the structural diff catches the behavior change)

Effect count: 2 → 2

Is either branch doing work you cannot cancel? Parallel is not a free speed win when one operation has observable side effects and the other can fail first.

Raw analyzer explain (before / after)
prepareTransfer (generator):
1. Yields rate <- deps.fetchRate
2. Yields balance <- deps.getBalance
Error paths: RateUnavailableError
Concurrency: sequential (no parallelism)
prepareTransfer (generator):
1. [rate, balance] = Runs 2 effects in sequential (concurrency: unbounded):
Calls deps.fetchRate
Calls deps.getBalance
Error paths: RateUnavailableError
Concurrency: uses parallelism / racing

One wrapper call. Confirmation is no longer required for success.

Someone wrapped sendConfirmation in Effect.orElseSucceed so confirmation failures stop failing the workflow.

The body of the generator changed slightly — a single line grew an outer call. Easy to skim past.

--- before.ts
+++ after.ts
@@ -1,4 +1,4 @@
-// PR #3 — before: confirmation failure fails the workflow
+// PR #3 — after: confirmation is best-effort
import { Effect } from 'effect';
type TransferId = { id: string };
@@ -19,6 +19,9 @@
export const completeTransfer = (deps: Deps) => () =>
Effect.gen(function* () {
const transfer = yield* deps.executeTransfer();
- const receipt = yield* deps.sendConfirmation(transfer.id);
+ const receipt = yield* Effect.orElseSucceed(
+ deps.sendConfirmation(transfer.id),
+ () => null as ConfirmationReceipt | null,
+ );
return { transfer, receipt };
});

The structural diff is unambiguous: sendConfirmation moved from generatorerror-handler and a new error-handler block was added. The after-state railway no longer has a ConfirmationFailedError branch — the diagram itself tells the story.

Structural diff:

Effect Program Diff: completeTransfer → completeTransfer

Section titled “Effect Program Diff: completeTransfer → completeTransfer”
MetricCount
Added1
Removed0
Renamed1
Moved0
Unchanged1
Structural changes1
~ **deps.sendConfirmation** (renamed from `effect-2` → `effect-7`)
**Effect** (added, id: `effect-6`)
    • error-handler block added

Before → After railway:

flowchart LR
  A["transfer #lt;- deps.executeTransfer"] -->|ok| B["receipt #lt;- deps.sendConfirmation"]
  B -->|ok| Done((Success))
  A -->|err| AE["TransferRejected"]
  B -->|err| BE["ConfirmationFailed"]
flowchart LR
  A["transfer #lt;- deps.executeTransfer"] -->|ok| Done((Success))
  A -->|err| AE["TransferRejected"]

Declared error union: declared error union unchanged (only the structural diff catches the behavior change)

Effect count: 2 → 3

Did the PR author intend to swallow confirmation failures? If yes: is there a dead-letter queue, reconciliation job, or alert? If no: this is a bug the reviewer just caught by reading the diagram instead of the source.

Raw analyzer explain (before / after)
completeTransfer (generator):
1. Yields transfer <- deps.executeTransfer
2. Yields receipt <- deps.sendConfirmation
Error paths: ConfirmationFailedError, TransferRejectedError
Concurrency: sequential (no parallelism)
completeTransfer (generator):
1. Yields transfer <- deps.executeTransfer
2. receipt = Handles errors (orElseSucceed):
Calls Effect
Handler:
Calls deps.sendConfirmation
Error paths: ConfirmationFailedError, TransferRejectedError
Concurrency: sequential (no parallelism)

A new service requirement quietly enters the workflow.

The PR introduces a FraudCheck service and verifies each transfer before execution.

Looks like a standard “add dependency, call it” change. The interesting detail — a new service in the R channel — is easy to miss when the Context.Tag class definition takes more visual space than the call.

--- before.ts
+++ after.ts
@@ -1,8 +1,9 @@
-// PR #4 — before: execute transfer directly
+// PR #4 — after: adds a fraud check before execution
import { Context, Effect } from 'effect';
type TransferId = { id: string };
type TransferRejectedError = { _tag: 'TransferRejectedError' };
+type FraudDeniedError = { _tag: 'FraudDeniedError' };
export class Payments extends Context.Tag('Payments')<
Payments,
@@ -13,9 +14,18 @@
}
>() {}
+export class FraudCheck extends Context.Tag('FraudCheck')<
+ FraudCheck,
+ {
+ readonly verify: (amount: number) => Effect.Effect<void, FraudDeniedError>;
+ }
+>() {}
+
export const initiateTransfer = (amount: number) =>
Effect.gen(function* () {
const payments = yield* Payments;
+ const fraud = yield* FraudCheck;
+ yield* fraud.verify(amount);
const transfer = yield* payments.execute(amount);
return transfer;
});

The structural diff lists both the new service yield and the new verify call as added steps. The services map for this workflow now has an extra node; anyone wiring this program up for tests or production needs to provide a FraudCheck implementation.

Structural diff:

Effect Program Diff: initiateTransfer → initiateTransfer

Section titled “Effect Program Diff: initiateTransfer → initiateTransfer”
MetricCount
Added2
Removed0
Renamed0
Moved0
Unchanged2
Structural changes0
**FraudCheck** (added, id: `effect-8`)
**fraud.verify** (added, id: `effect-9`)

Before → After railway:

flowchart LR
  A["payments #lt;- Payments"] -->|ok| B["transfer #lt;- payments.execute"]
  B -->|ok| Done((Success))
  B -->|err| BE["TransferRejected"]
flowchart LR
  A["payments #lt;- Payments"] -->|ok| B["fraud #lt;- FraudCheck"]
  B -->|ok| C["transfer #lt;- payments.execute"]
  C -->|ok| Done((Success))
  C -->|err| CE["TransferRejected"]

Declared error union: + FraudDeniedError

Effect count: 2 → 4

What is the failure story if FraudCheck is down? A hard dependency means outages cascade. A soft dependency means fraud policies have gaps. Either is a product decision, not a reviewer decision.

Raw analyzer explain (before / after)
initiateTransfer (generator):
1. Yields payments <- Payments
2. Yields transfer <- payments.execute
Services required: Payments
Error paths: TransferRejectedError
Concurrency: sequential (no parallelism)
initiateTransfer (generator):
1. Yields payments <- Payments
2. Yields fraud <- FraudCheck
3. Calls fraud.verify
4. Yields transfer <- payments.execute
Services required: Payments, FraudCheck
Error paths: FraudDeniedError, TransferRejectedError
Concurrency: sequential (no parallelism)

A resilience regression disguised as a cleanup.

Someone removed the Effect.retry(Schedule.exponential(...)) wrapper around the whole transfer, with a commit message like “simplify workflow — provider handles retries now”.

The diff is a single deleted line: .pipe(Effect.retry(...)). This is the kind of change that passes review in 30 seconds because the reviewer reads “cleanup” and “simpler” and moves on.

--- before.ts
+++ after.ts
@@ -1,5 +1,5 @@
-// PR #5 — before: confirmation call retried on transient failure
-import { Effect, Schedule } from 'effect';
+// PR #5 — after: retry removed during "cleanup" refactor
+import { Effect } from 'effect';
type TransferId = { id: string };
type TransferRejectedError = { _tag: 'TransferRejectedError' };
@@ -20,4 +20,4 @@
const transfer = yield* deps.executeTransfer();
yield* deps.sendConfirmation(transfer.id);
return transfer;
- }).pipe(Effect.retry(Schedule.exponential('200 millis').pipe(Schedule.compose(Schedule.recurs(3)))));
+ });

The structural diff flags it immediately: - retry block removed. All runtime tests still pass — retries only matter under transient failure, and unit tests rarely simulate the exact timing that made the retry useful. The tool catches what tests cannot.

Structural diff:

Effect Program Diff: completeTransfer → completeTransfer

Section titled “Effect Program Diff: completeTransfer → completeTransfer”
MetricCount
Added0
Removed1
Renamed0
Moved0
Unchanged2
Structural changes1
**Schedule.exponential('200 millis').pipe** (removed, id: `effect-4`)
    • retry block removed

Before → After railway:

flowchart LR
  A["retry: Schedule.exponential#lpar;'200 millis'#rpar;.pipe#lpar;Schedule.comp…"] -->|ok| Done((Success))
  A -->|err| AE["TransferRejected / ConfirmationFailed"]
flowchart LR
  A["transfer #lt;- deps.executeTransfer"] -->|ok| Done((Success))
  A -->|err| AE["TransferRejected"]

Declared error union: declared error union unchanged (only the structural diff catches the behavior change)

Effect count: 3 → 2

Is there actually upstream retry, or does the team assume there is? “The provider handles it” is often aspirational. The answer determines whether this PR is a safe simplification or a resilience regression. The diff turns an implicit assumption into an explicit review question.

Raw analyzer explain (before / after)
completeTransfer (generator):
1. Retries with Schedule.exponential('200 millis').pipe(Schedule.compose(Schedule.recurs(3))):
Yields transfer <- deps.executeTransfer
Calls deps.sendConfirmation
Error paths: ConfirmationFailedError, TransferRejectedError
Concurrency: sequential (no parallelism)
completeTransfer (generator):
1. Yields transfer <- deps.executeTransfer
2. Calls deps.sendConfirmation
Error paths: ConfirmationFailedError, TransferRejectedError
Concurrency: sequential (no parallelism)

Every scenario above is a shape of PR that teams ship. None of them are contrived. The common thread is that the source diff is a faithful record of the text change, and a lossy record of the behavior change.

  • PR #1 adds a failure mode. The source diff tells you a line was added.
  • PR #2 changes concurrency semantics. The source diff tells you some lines were restructured.
  • PR #3 silences a failure. The source diff tells you an outer call was added.
  • PR #4 adds a dependency. The source diff tells you a new import and a new yield appeared.
  • PR #5 removes a resilience primitive. The source diff tells you a wrapper call was deleted.

The analyzer’s output, structural diff, error surface, railway diagram, answers a different question: what about the behavior of this program is different now? That is the question every reviewer is trying to answer in their head while scrolling a diff. The tool just does it more reliably.

  • Semantic Diff — the feature powering every “Structural diff” block on this page.
  • Transfer Observability — the narrative around why the transfer example is worth analyzing at all.
  • Transfer Evolution — the five-step build-up of the same workflow, showing the error channel grow step by step.