Skip to content

Side Effects

Side effects — logging, analytics, notifications, audit trails — are unavoidable in real applications. OOFP provides a family of tap operators that let you perform side effects at precise points in a pipeline without altering the value flowing through it.

The key decision is: should the side effect block the pipeline, and should its failure propagate?

import { pipe } from "@oofp/core/pipe";
import * as M from "@oofp/core/maybe";
import * as E from "@oofp/core/either";
import * as TE from "@oofp/core/task-either";
import * as RTE from "@oofp/core/reader-task-either";

Synchronous taps execute immediately, never fail the pipeline, and are available on all monads.

Runs a synchronous side effect on the success value. The return value of the callback is ignored.

// Maybe
pipe(
M.fromNullable(user),
M.tap((u) => console.log("Found user:", u.name)),
M.map((u) => u.email),
);
// Either
pipe(
E.right(42),
E.tap((n) => console.log("Value:", n)),
E.map((n) => n * 2),
);
// TaskEither
pipe(
getUser("123"),
TE.tap((user) => console.log("Fetched:", user.name)),
TE.map((user) => user.email),
);

tapLeft (Either, TaskEither, ReaderTaskEither)

Section titled “tapLeft (Either, TaskEither, ReaderTaskEither)”

Runs a synchronous side effect on the error value. Does nothing on success.

pipe(
getUser("unknown"),
TE.tapLeft((err) => console.error("Failed:", err.message)),
TE.map((user) => user.name),
);

Runs a synchronous side effect when the value is Nothing. Does nothing on Just.

pipe(
M.fromNullable(cachedValue),
M.tapNothing(() => console.warn("Cache miss")),
M.getOrElse(defaultValue),
);

Runs a synchronous side effect with access to both the success value and the context.

pipe(
findUser(id),
RTE.tapR((user, ctx) => ctx.logger.info(`Found user: ${user.name}`)),
RTE.map((user) => user.email),
);

Async side effects are only available on TaskEither and ReaderTaskEither. They come in three variants with very different behavior. Choosing the wrong one is a common source of bugs.

VariantAwaited?Error propagated?Use case
tapTEYesYesCritical: audit logs, compliance
tapTEAsyncNoNoFire-and-forget: analytics, metrics
tapTEDetachedNoNo (callback)Fire-and-forget + error handling
tapLeftTEYesYesError-path side effects
tapLeftTEAsyncNoNoFire-and-forget on error
tapLeftTEDetachedNoNo (callback)Fire-and-forget on error + callback
VariantAwaited?Error propagated?Use case
tapRTEYesYesCritical: audit logs with context
tapRTEAsyncNoNoFire-and-forget: analytics with context
tapRTEDetachedNoNo (callback)Fire-and-forget + error handling with context
tapLeftRTEYesYesError-path side effects with context
tapLeftRTEAsyncNoNoFire-and-forget on error with context
tapLeftRTEDetachedNoNo (callback)Fire-and-forget on error + callback with context

The side effect is awaited. If it fails, the error propagates into the pipeline and the pipeline fails. Use this for side effects that must succeed for the operation to be considered complete.

const writeAuditLog = (user: User): TE.TaskEither<AuditError, void> => /* ... */;
const createUser = (input: UserInput) =>
pipe(
insertUser(input), // TE<DbError, User>
TE.tapTE(writeAuditLog), // TE<DbError | AuditError, User>
);
// If audit log fails, the whole pipeline fails.
// Error type is widened to DbError | AuditError.

With RTE and context access:

const writeAuditLog = (user: User): RTE.ReaderTaskEither<AuditContext, AuditError, void> => /* ... */;
const createUser = (input: UserInput) =>
pipe(
insertUser(input), // RTE<DbContext, DbError, User>
RTE.tapRTE(writeAuditLog), // RTE<DbContext & AuditContext, DbError | AuditError, User>
);

The side effect is launched but not awaited. The pipeline continues immediately. If the side effect fails, the error is silently swallowed. Use this for non-critical side effects where you don’t want to slow down or risk the main operation.

const trackAnalytics = (user: User): TE.TaskEither<never, void> => /* ... */;
const createUser = (input: UserInput) =>
pipe(
insertUser(input), // TE<DbError, User>
TE.tapTEAsync(trackAnalytics), // TE<DbError, User> — error type NOT widened
);
// Analytics runs in the background. Pipeline does not wait.
// If analytics fails, nobody knows.

Fire-and-Forget with Error Handling: tapTEDetached / tapRTEDetached

Section titled “Fire-and-Forget with Error Handling: tapTEDetached / tapRTEDetached”

Like tapTEAsync, but accepts an error callback so you can observe failures without blocking the pipeline. Use this when you want fire-and-forget semantics but still need to log or alert on side effect failures.

const sendWelcomeEmail = (user: User): TE.TaskEither<EmailError, void> => /* ... */;
const createUser = (input: UserInput) =>
pipe(
insertUser(input),
TE.tapTEDetached(
sendWelcomeEmail,
(err) => console.error("Welcome email failed:", err),
),
);
// Email is fire-and-forget, but failures are logged.

With RTE:

const syncToExternalCRM = (user: User): RTE.ReaderTaskEither<CrmContext, CrmError, void> => /* ... */;
const createUser = (input: UserInput) =>
pipe(
insertUser(input),
RTE.tapRTEDetached(
syncToExternalCRM,
(err) => alertOpsChannel(`CRM sync failed: ${err.message}`),
),
);

Error-Path Side Effects: tapLeftTE / tapLeftRTE

Section titled “Error-Path Side Effects: tapLeftTE / tapLeftRTE”

These run only when the pipeline is in the error channel. The same three variants apply (awaited, async, detached).

tapLeftTE — Awaited error-path side effect

Section titled “tapLeftTE — Awaited error-path side effect”
const reportError = (err: AppError): TE.TaskEither<never, void> =>
TE.fromPromise(() =>
fetch("/api/errors", {
method: "POST",
body: JSON.stringify(err),
}).then(() => {}),
);
const getUser = (id: string) =>
pipe(
fetchUser(id),
TE.tapLeftTE(reportError), // awaited — if reporting fails, pipeline error changes
);

tapLeftTEAsync — Fire-and-forget on error

Section titled “tapLeftTEAsync — Fire-and-forget on error”
const trackErrorMetric = (err: AppError): TE.TaskEither<never, void> => /* ... */;
const getUser = (id: string) =>
pipe(
fetchUser(id),
TE.tapLeftTEAsync(trackErrorMetric), // fire-and-forget
);

tapLeftTEDetached — Fire-and-forget on error with callback

Section titled “tapLeftTEDetached — Fire-and-forget on error with callback”
const getUser = (id: string) =>
pipe(
fetchUser(id),
TE.tapLeftTEDetached(
reportToSentry,
(reportErr) => console.error("Sentry report failed:", reportErr),
),
);

// WRONG — analytics failure kills the entire pipeline
const createUser = (input: UserInput) =>
pipe(
insertUser(input),
RTE.tapRTE(trackAnalytics), // if analytics service is down, user creation fails!
);
// CORRECT — use fire-and-forget for non-critical side effects
const createUser = (input: UserInput) =>
pipe(
insertUser(input),
RTE.tapRTEAsync(trackAnalytics), // analytics failure is silently ignored
);
// WRONG — audit log might not complete before the response is sent
const createUser = (input: UserInput) =>
pipe(
insertUser(input),
TE.tapTEAsync(writeAuditLog), // fire-and-forget — audit log may be lost
);
// CORRECT — audit must complete before we respond
const createUser = (input: UserInput) =>
pipe(
insertUser(input),
TE.tapTE(writeAuditLog), // awaited — pipeline waits for audit to complete
);

Mistake: Ignoring errors on important side effects

Section titled “Mistake: Ignoring errors on important side effects”
// WRONG — email failure is completely invisible
const onboardUser = (user: User) =>
pipe(
activateAccount(user),
TE.tapTEAsync(sendWelcomeEmail), // if email fails, nobody knows
);
// CORRECT — use detached with error callback for observability
const onboardUser = (user: User) =>
pipe(
activateAccount(user),
TE.tapTEDetached(
sendWelcomeEmail,
(err) => alertOpsChannel(`Welcome email failed for ${user.id}: ${err}`),
),
);

Is the side effect critical (audit, compliance)?
├── YES → Use tapTE / tapRTE (awaited, error propagates)
└── NO → Is failure observation needed?
├── YES → Use tapTEDetached / tapRTEDetached (fire-and-forget + callback)
└── NO → Use tapTEAsync / tapRTEAsync (fire-and-forget, silent)
Side effectVariant
Audit logtapTE / tapRTE
Compliance recordtapTE / tapRTE
Analytics eventtapTEAsync / tapRTEAsync
Metrics countertapTEAsync / tapRTEAsync
Welcome emailtapTEDetached / tapRTEDetached
Webhook notificationtapTEDetached / tapRTEDetached
Error reporting (Sentry)tapLeftTEDetached / tapLeftRTEDetached
Error metricstapLeftTEAsync / tapLeftRTEAsync
Console loggingtap / tapLeft