Definition
A pure function returns the same result for the same input values and has no observable side effects: it does not change caller-owned data, shared state or the outside world.
01Find what the call touches
One endpoint call does three jobs.
A SaaS backend reports how long an account’s trial has left. Its first implementation reads the service’s current day, calculates a number, then records that the account was inspected. For one endpoint call, these operations are easy to follow together.
Now support wants to preview a future day, and a reminder job needs the calculation without recording an inspection. A test that passes only the trial still depends on the service clock. Calling the endpoint helper just to obtain a number also adds an audit entry.
type Trial = { endsOn: number };
let serviceDay = 40;
const audit: string[] = [];
const readDay = () => serviceDay;
function trialStatus(trial: Trial): number {
const today = readDay();
const daysLeft = Math.max(0, trial.endsOn - today);
audit.push(`inspected:${daysLeft}`);
return daysLeft;
} Separate the decision from the operations that read or change the world. A pure calculation uses the supplied values and returns its answer. The caller chooses when to read the day and when to record an inspection.
The clock read is a hidden input. The audit append is a side effect. A function that always returns the same number while appending another entry still has an effect.
02Separate decide from act
Supply the day. Return the answer.
The calculation receives the trial and a day. It subtracts, clamps at zero and returns. The inspection caller reads the service day and records an audit entry around that calculation. This is the complete program Explore runs.
The two direct calculations use one sampled day, even though the service day advances between them. The later inspection goes through the caller’s clock and audit boundary.
type Trial = { endsOn: number };
let serviceDay = 40;
const audit: string[] = [];
const readDay = () => serviceDay;
// #region core
function remainingAt(trial: Trial, today: number): number {
const daysLeft = Math.max(0, trial.endsOn - today);
return daysLeft;
}
// #endregion
function inspectTrial(trial: Trial): number {
const today = readDay();
const daysLeft = remainingAt(trial, today);
audit.push(`inspected:${daysLeft}`);
return daysLeft;
}
const trial: Trial = { endsOn: 43 };
const today = readDay();
const first = remainingAt(trial, today);
console.log(`first: ${first}`);
serviceDay = 41;
const second = remainingAt(trial, today);
console.log(`second: ${second}`);
console.log(`audit before shell: ${audit.length}`);
const inspected = inspectTrial(trial);
console.log(`shell: ${inspected}`);
console.log(`audit after shell: ${audit.length}`); | Role | What it owns |
|---|---|
| Request boundary | Reads the current day and validates input; decides whether an inspection should be recorded. |
| Calculation | Receives trial end and day as values; returns remaining days without changing either. |
| Effect boundary | Appends the requested audit entry; owns failures and retries of a real audit service. |
You can test the calculation at expiry or a week earlier by changing its input values. The service still performs necessary effects; the reusable calculation does not perform them as a condition of returning an answer.
03Test the boundary
Is a replaceable clock enough?
Experiment
A calculation and its caller
A reviewer proposes this alternate API. Replace the two direct calls with it, supplying the same clock function each time. The service day advances from 40 to 41 between calls; the trial still ends on day 43.
// Add this helper beside the calculation:
function remainingUsing(trial: Trial, clock: () => number): number {
return remainingAt(trial, clock());
}
// Replace the block from the sampled-day declaration
// through the second output with:
const first = remainingUsing(trial, readDay);
console.log(`first: ${first}`);
serviceDay = 41;
const second = remainingUsing(trial, readDay);
console.log(`second: ${second}`); Reading the TypeScriptWhat the type system does and does not establish
A function type such as () => number describes inputs and return values;
it does not promise purity. A const binding also does not freeze an object. The
calculation reads the supplied trial without assigning to it or to anything reachable from
it.
04Use it in a service
A preview needs the decision, without the delivery.
A reminder service needs more than one remaining-days value. It selects accounts inside a reminder window and prepares notice data. Support previews that plan; the scheduled job sends it. Both callers can share the decision while owning different effects.
For a window of two days at day 40, accounts ending on days 40, 41 and 42 are due. An account ending on day 39 is expired; one ending on day 50 is too far away. Keep the supplied order. The lab builds this selection, extends it to a notice plan, then reviews a long-lived service that reuses its first plan.
This boundary also appears in request validation, price calculation and permission decisions: supply the relevant facts, return a decision, and have the caller perform the write or send. Calling a calculation twice should not itself charge, save or deliver twice.
In the frontend A trial preview in Svelte; the same boundary in React Optional
The frontend can use the same calculation to display a trial preview. A parent supplies the trial and day; rendering derives the text. Dismiss is an explicit user action whose handler belongs to the caller. Rendering the preview does not record a dismissal.
In Svelte 5, derived expressions should be free of side effects. React’s rendering guidance draws the same boundary: calculate from rendering inputs and keep effects outside render. Framework state still controls updates; a pure helper does not subscribe to midnight or schedule a refresh.
<script lang="ts">
import { remainingAt, type Trial } from '../remaining.ts';
let {
trial,
today,
onDismiss
}: {
trial: Trial;
today: number;
onDismiss: () => void;
} = $props();
const daysLeft = $derived(remainingAt(trial, today));
</script>
<p>{daysLeft} days left in your trial.</p>
<button type="button" onclick={onDismiss}>Dismiss</button>
remainingAt is the calculation above, exported from remaining.ts. The parent owns updating today, handling time-zone
policy and persisting a dismissal.
05Keep the limits visible
Repeatable does not mean current, cheap or delivered.
| Situation | Decision |
|---|---|
| One short command reads a day and writes one audit entry | Keeping the operations together can be clear. Split when you need a reusable decision or independent tests. |
| Support previews the reminder batch; a job later sends it | Return the plan from supplied values. Let the explicit delivery caller perform the sends. |
| A request only saves a dismissal | Keep the necessary write at a clear boundary. An empty pure wrapper adds no useful decision. |
| A preview is reused tomorrow | Recompute, or reuse only for equal relevant input values. Purity does not keep old results current. |
Purity describes the whole call. Sorting a caller’s collection, changing one of its records, logging, writing a file or sending a request creates an observable effect. Filling a fresh local result collection is fine when that mutation stays inside the call until return.
Equal inputs means equal values. Reusing the same object reference after changing its fields is a new input value. A pure function can return a mutable result without freezing it; its contract must say whether it shares data with the caller. A shallow copy does not isolate nested records.
What this boundary leaves for productionValidation, time policy, cost and delivery failure
Validate and normalize external data before calculating: reject invalid dates, choose a calendar convention and keep arithmetic inside the supported range. The exercise uses small day indices to focus on the boundary, not to offer a production date library.
Pure code can be slow or allocate too much. Keeping calculations separate makes cost easier to measure; it does not remove it. A cache needs all relevant inputs and a policy for shared results. For this small calculation, recomputing is clearer.
A real send may fail after another succeeds. An audit write may fail after the status was calculated. Define the caller’s error, retry and partial-success policy. Purity of the calculation gives no exactly-once delivery guarantee.
Functional core, imperative shell develops this into application architecture. The neighboring Memoization topic concerns reuse; idempotence concerns repeated operations and effects. Neither replaces the two-part definition of purity.
06Make the call
What belongs in the calculation?
Before opening the answer, name the hidden input and the effect in the original endpoint. Then explain why a clock function is different from the day it returns.
One way to explain itInputs, return values and observable changes
The original helper obtained a day the call did not name and appended an audit entry outside the calculation. The pure version receives the day as a value and returns remaining days without observable changes. Supplying a clock makes it replaceable, but invoking it can still obtain a new value. The caller samples facts and performs requested effects; the calculation can be tested, repeated and previewed independently.
Practice in the lab
Start with trial records and a day already chosen by the service. Select due accounts, add a preview and delivery boundary, then review a cache across requests. Each level starts from the previous level’s completed design plus its new requirement; you can begin at any level.