← Concepts & practices
Concept Design principles and language mechanisms

Currying & partial application

Bind what you know now; name the work that comes later.

You already do this. You write onClick={() => remove(id)}, and the arrow keeps id until the click. In Go, logger.With("request", id) binds a request to a logger once. Let’s follow a request logger from repeating its id at every call to the day a shared closure writes the wrong request’s id.

TypeScriptGo One logger, two implementations.

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
logger.ts
// 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.

TypeScriptReading
logger.ts
// 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;
GoAlongside
logger.go
// 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.

Currying & partial application

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.

01/ 05
Write two direct log entries

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, and error describe 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 carry req-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.

Which request id does the first logger write?

The server runs const shared = sharedLogger(sink) once. Request handling then runs const first = shared.forRequest('req-first'), then shared.forRequest('req-second'), and finally call first('finished').

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.

forRequest

Owns setup

Captures the sink and request id for one scope.

withLevel

Groups a phase

Captures a level when several messages share it.

Message

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.

client.ts
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.

ReactAlready in your code
SaveButton.tsx
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.

Familiar places where a function is configured before use
Where you’ve seen itThe callBound firstSupplied later
A list row’s buttononClick={() => remove(id)}The row’s idThe click
A Go loggerlogger.With("request", id)The request attributeEach message and level
A text replacerstrings.NewReplacer("&", "&amp;")The replacement pairsEach string passed to Replace
Static file middlewareexpress.static('public')The root directoryEach request
A collection callbackitems.filter(isOver(100))The thresholdEach 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.

Read the partial application example ↗

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.

Read the standard-library API ↗

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.

Read the standard-library API ↗
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.

How a change affects a direct function and a bound logger
The changeA direct writeLogA logger bound per request
A script logs one lineAlready clear. All four values are at the call site.Extra setup and a name for no reuse.
A request logs ten messagesRepeats the sink and the request id ten times.Bind once. Each call says only what’s new.
Messages in one phase share a levelPass the level at each call.Bind the phase with withLevel; keep write for the rest.
A request can finish after another startsEach call carries its own id.Safe only with a closure per request, never a shared mutable capture.
The sink changes per messagePass 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.

Take the logger into your editor. Replace the shared mutable capture with a fresh closure per request, then decide whether each group of arguments deserves a name.

Back to Concepts & practices →