01 / The idea
Repeating the request id is a fair start.
You’re handling one request through several functions. At the start you know req-42 and the sink that receives logs. A little later, one helper knows the
message is loaded; another knows a response was slow. writeLog takes all four values where the work happens, and when a script has all
four at one call site, that is the clearest thing you can write.
Read the first loggerTypeScript · the version this lesson starts from
// The fair first version: every call repeats the context it needs.
export function writeLog(sink: Sink, requestId: string, level: Level, message: string): void {
sink.write({ requestId, level, message });
} Go’s writeLog takes the same four values. Both implementations meet again when
the setup values become a returned function.
Then the request grows. The id and the sink get threaded through helpers that don’t
otherwise care about them, and every new helper adds two parameters it only passes along.
Someone suggests currying and shows add(4)(5). It works, but the arithmetic
doesn’t say which values belong together.
Currying changes a function of several arguments into a sequence of functions, each taking one argument. Partial application supplies some arguments now and returns a function for the rest. The useful question is not “can I take this one argument at a time?” but “which values are known together, and what operation happens later?”
If you write components, you already work this way. onClick={() => remove(id)} is partial application of remove: the row knows id when it renders,
and the click supplies the moment. Section 05 follows that into an export button.
02 / See the shape
Group the arguments by when they become known.
The basic form is the whole mechanism: a function that returns a function, which remembers what it was given. Switch to In the wild for the logger that binds the sink and request id, and At the call site for the direct calls and the bound ones side by side.
Both languages write the same five entries, checked against one shared expected file. Each says it in its own way.
The smallest mechanism: a generic curry helper and a two-argument add. Each returned function remembers the earlier argument.
// Currying turns one multi-argument function into a chain of one-argument functions.
export function curry4<A, B, C, D, R>(fn: (a: A, b: B, c: C, d: D) => R) {
return (a: A) => (b: B) => (c: C) => (d: D) => fn(a, b, c, d);
}
export const curriedAdd = (a: number) => (b: number) => a + b; // Currying turns one multi-argument function into a chain of one-argument functions.
// Go spells out every returned function type, and a type parameter cannot stand for
// "no result", so fn must return something.
func curry4[A, B, C, D, R any](fn func(A, B, C, D) R) func(A) func(B) func(C) func(D) R {
return func(a A) func(B) func(C) func(D) R {
return func(b B) func(C) func(D) R {
return func(c C) func(D) R {
return func(d D) R { return fn(a, b, c, d) }
}
}
}
}
func curriedAdd(a int) func(int) int {
return func(b int) int { return a + b }
} Reading the TypeScriptGeneric currying and closures
curry4 makes the shape explicit: after curry4(writeLog)(sink), the returned function still needs a request id,
level, and message. forRequest is partial application with a useful boundary:
it binds the two values known when the request starts.
The returned methods are arrow functions, so the logger doesn’t depend on a later
receiver or a mutable this. Each call to forRequest creates its
own captured request id.
Reading the GoEvery returned type written out
Go’s curry4 is the same mechanism with each returned function type spelled out.
A type parameter can’t stand for “no result,” so it needs a function that returns a value;
the test curries a four-argument function that writes an entry and returns it.
The practical code skips the helper. forRequest returns a BoundLogger whose fields are functions, which says more about the logger than
a general helper would. The sink is an interface, so the same logger can write to a recorder
in a test or another sink in production.
03 / Follow the binding
Watch the setup values disappear from later calls.
Five steps, all running the TypeScript you just read. The left column shows what gets called; the sink on the right shows the request id and level that survived. Before each step, guess which values the next call still has to supply.
In Try it, choose the binding moment yourself and write several messages through it.
When do you know each argument?
All arguments at the use site. writeLog(sink, 'req-42', 'info', 'loaded') written. writeLog(sink, 'req-42', 'info', 'saved') written. Sink received [req-42] info: loaded; [req-42] info: saved. Every caller repeats the request id. Nothing is wrong; the context just travels through every call.
Context repeated.
The request id is available, but every direct call carries it again.
Reduced motion: choose a scene to see its completed state.
Read this scene
The request id is available, but every direct call carries it again.
All arguments at the use site. writeLog(sink, 'req-42', 'info', 'loaded') written. writeLog(sink, 'req-42', 'info', 'saved') written. Sink received [req-42] info: loaded; [req-42] info: saved. Every caller repeats the request id. Nothing is wrong; the context just travels through every call.
Watch restarts when you return. Try it starts with a fresh sink each time you open it.
What a well-placed closure buys you
Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at the logger on this page.
- Configuration once
forRequest(sink, 'req-42')takes the sink and the id at setup. No later call names either of them.- Fewer threaded arguments
- Later calls say
requestLog.info('cached'), not four values of which only one is new. - A named operation
info,warn, anderrordescribe the remaining work.curry4(writeLog)(sink)only describes how many arguments are left.- Independent scopes
forRequest(sink, 'req-99')makes a second closure. Its entries never carryreq-42.- Phase-shaped APIs
withLevel(requestLog, 'warn')binds one more value when a group of messages shares it.
None of that is free. Section 08 is the other side of the ledger: a returned function keeps what it captured for as long as anything holds it.
04 / Try a decision
A shared logger looks reusable until two requests overlap.
Someone wants every helper to stop taking a request id. They make one sharedLogger(sink) for the server, and its forRequest stores the id
in a variable that the returned functions read. The tests log one request at a time and pass.
In production, a second request starts before the first one finishes.
05 / Give it a real job
Bind a request or an export once. Let the event supply the detail.
In a real application, a request handler creates a logger with the request id as the request arrives. An export page binds the workspace and format while its settings are known, then takes a file name when someone clicks Export.
Owns setup
Captures the sink and request id for one scope.
Groups a phase
Captures a level when several messages share it.
Arrives later
Stays at the call site where the work happens.
The example leaves out async context propagation, redaction, batching, and structured fields. Those are separate policies. None of them change which values are known when.
Build UIs?Every handler that closes over a prop is partial application, and one day an export button makes you own the capture’s lifetime.
Where it already is in your components
You bind arguments in components all day. A list of invoices renders a delete button per
row with onClick={() => remove(invoice.id)}. The row knows the id when it
renders, and the click supplies the moment. Svelte’s onclick={() => remove(invoice.id)} is the same closure.
The textbook snippets make that split explicit: a save button binds its request with forRequest, and the handler supplies only the message.
When you have to own it
Now it’s an export button. The page knows the workspace and format before anyone clicks,
the file name changes as the user types, and an export must never go to the workspace the
user just left. Bind the stable values once with forWorkspace, keep the file
name at the click, and tie the bound action’s lifetime to the values it captured.
In React that lifetime is the useMemo dependency list: a new workspace or
format makes a new action. Written as useCallback(…, []), the action would
keep the first workspace for as long as the component lives. In Svelte, $derived re-runs when workspaceId or format changes. Neither needs a chain
of one-argument functions for its own sake.
export type LogSink = (line: string) => void;
export function forRequest(requestId: string, sink: LogSink): (message: string) => void {
return (message) => sink(`[${requestId}] ${message}`);
}
export type ExportFormat = 'csv' | 'json';
// Bind what the page knows before the click. The click supplies the file name.
export function forWorkspace(workspaceId: string, format: ExportFormat) {
return (file: string) =>
fetch(`/api/workspaces/${encodeURIComponent(workspaceId)}/exports`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ format, file: file.trim() || 'export' })
});
}
A save button binds its request before the click and supplies only the message when the event happens.
import { useState } from 'react';
import { forRequest } from './client';
export function SaveButton({ requestId }: { requestId: string }) {
const [saved, setSaved] = useState(false);
const log = forRequest(requestId, (line) => console.info(line));
function save() {
setSaved(true);
log('draft saved'); // The handler supplies the value only known at click time.
}
return <button onClick={save}>{saved ? 'Saved' : 'Save draft'}</button>;
}
06 / Recognize it elsewhere
Look for a value fixed before the work begins.
Partial application often hides behind a factory, a callback, or a handler. Here are a few places you’ve probably bound arguments without calling it that.
| Where you’ve seen it | The call | Bound first | Supplied later |
|---|---|---|---|
| A list row’s button | onClick={() => remove(id)} | The row’s id | The click |
| A Go logger | logger.With("request", id) | The request attribute | Each message and level |
| A text replacer | strings.NewReplacer("&", "&") | The replacement pairs | Each string passed to Replace |
| Static file middleware | express.static('public') | The root directory | Each request |
| A collection callback | items.filter(isOver(100)) | The threshold | Each item |
In each row, the returned value makes the remaining call easier to read. If it only saves a few characters, keep the original function.
07 / Already in your toolbox
The libraries you use already bind arguments for you.
Three APIs to look at. For each one, find what is bound first and what arrives later.
JavaScript · Function.prototype.bind
Besides fixing this, bind takes leading arguments and returns a function
that prepends them to whatever the later call passes. MDN calls these partially applied functions.
Go · slog.Logger.With
Returns a logger that includes the given attributes in every output. It is forRequest in the standard library: bind the request once, log messages later.
Go · strings.NewReplacer
Takes the old and new string pairs up front and returns a *Replacer. Each
call to Replace supplies only the text. Build it once and reuse it.
A useful counterexample: a one-off slog.InfoSometimes the direct call is the design
The package that offers With also offers slog.Info(msg, args...), which takes the message and every attribute in one
call. For a line logged once, with nothing shared by later calls, that’s the right choice.
Binding would add a logger to name and pass around for no reuse.
The same holds for writeLog(sink, id, level, message) in a script that has
all four values at one call site. See the slog.Info reference.
08 / The parts to watch
A returned function is a small object with a lifetime.
Binding removes parameters from a call. The decisions those values stood for are still yours.
A capture can outlive the thing it describes
A request logger kept in a module-level collection can retain request data longer than intended. Scope it to the request, dispose of the registration, or make the lifetime visible in the owner.
A shared mutable capture mixes up requests
The exercise’s currentRequest is changed by the second setup call, so the first
returned function reads the wrong value. Capture a fresh parameter in each call, not one mutable
slot for all of them.
Strict currying can hide the domain boundary
curry4(writeLog)(sink)(id)(level)(message) demonstrates arity. forRequest(sink, id) explains why the first two values belong together. Prefer
the boundary a reviewer can name.
Captured values can become stale
If a workspace changes, an export function bound to the old workspace must be replaced. A stable function identity is not worth sending an export to the wrong destination.
Closures do not make effects safe
The logger still writes to a sink. Binding its context changes the call shape. It doesn’t make logging, networking, or mutation pure, ordered, or retry-safe.
09 / Make the call
What would you have to change tomorrow?
Give both designs a plausible change and follow the work it creates.
| The change | A direct writeLog | A logger bound per request |
|---|---|---|
| A script logs one line | Already clear. All four values are at the call site. | Extra setup and a name for no reuse. |
| A request logs ten messages | Repeats the sink and the request id ten times. | Bind once. Each call says only what’s new. |
| Messages in one phase share a level | Pass the level at each call. | Bind the phase with withLevel; keep write for the rest. |
| A request can finish after another starts | Each call carries its own id. | Safe only with a closure per request, never a shared mutable capture. |
| The sink changes per message | Pass it. Nothing to rebuild. | Rebuild the logger, or leave the sink unbound. |
Reach for partial application when values become known in phases and the returned operation has a name. The request id is the moment: known at the start, needed by every later line.
Keep the direct function when all the arguments arrive together. A one-off call, or a capture that would be harder to own than the repeated parameter, is shorter as it stands.
The question I’d leave beside the code is: which values are fixed for this scope, and who owns the returned function until its last call?
10 / Take the idea with you
Explain the logger without saying “currying.”
“The request id and sink are known when the request starts, so forRequest keeps
them in one closure per request. Each later call supplies only a level and a message, and a
second request gets its own closure.” That tells a reviewer more than the technique’s name
does. When the reviewer wants the word, it’s partial application: some
arguments now, the rest later.
Before moving on, jot down why the shared logger wrote req-second, why forRequest returns named methods instead of curry4’s chain, and
one handler in your own code that keeps a value it received earlier. Your last list of
delete buttons counts.
Connections to follow nextRelated lessons
- Closures and captured state traces what a function keeps from its surrounding scope, and for how long.
- Composition over inheritance builds routes from parts.
rateLimit(2, clientKey)is partial application: the limit and the key are bound at startup, and the request arrives later. - Dependency injection supplies collaborators from outside. Binding a sink into a logger is one way to hand over a configured collaborator.
- Pure functions and side effects separates a returned decision from the sink that performs the effect.
- Memoization also retains values, but it remembers results by input rather than configuring a later operation.