What must a wrapper preserve, beyond the parameter list and return type, so no caller can tell it is there?
answer
- signature fit is not behavioural fit
- failures, effect count, timing, concurrency
- caching changes how often the effect runs
- wrapper state is shared by all callers
- correctness depends on what you wrapped
basics
~20 sThe signature is only the floor. A truly invisible wrapper also preserves the failure behaviour, how many times the inner work actually happens, when it happens, and the safety guarantees callers already relied on under concurrency.
solid answer
~50 sMatching the signature makes the wrapper compile in the original's place; it does not make it invisible. Callers also depend on behaviour the type does not describe: that a failure they handled still arrives, that calling the function twice still does the work twice, that the work happens when they call rather than earlier or later, and that concurrent callers are as safe as before. A caching wrapper is the honest example — it keeps the signature perfectly, and it changes the effect count from one per call to one per distinct key. That is harmless over a pure lookup and a correctness bug over a function that also writes an audit record or takes a lock. So the question to ask of any wrapper is not 'does it still fit?' but 'which observable property did I just change?'.
go deeper
Remember that fitting in the same slot is not the same as behaving the same. Start with one concrete example: caching makes the inner function run fewer times than the caller called it.
Name the properties the signature does not carry — failure behaviour, how often the work runs, when it runs, safety under concurrent calls — and give an example of a wrapper that breaks each one.
Show the production version: an audit or billing side effect that stopped happening once a cache was wired in, or a retry added to an operation that could not be repeated, and how you found it.
The judgment is which wrappers teams may apply without review, and what they must be able to state about a function before wrapping it — the rule is about the wrapped, not the wrapper.
## The signature is the floor, not the contract A wrapper that takes a function and returns one with the same parameters and the same kind of result will slot in wherever the original went. That is a **type-level** fit. Callers, however, were written against a **behavioural** contract that the signature never spelled out, and a wrapper can violate all of it while fitting perfectly. ## What a caller actually depends on - **The failure path.** Which failures can arrive, and that they arrive rather than being converted or absorbed. A wrapper that turns an error into a neutral return value deletes the caller's handling silently. - **The number of times the inner work happens.** One call in, one execution. A wrapper that caches, batches, deduplicates or retries changes that number, in either direction. - **When the work happens.** A wrapper that does the work eagerly at wiring time, or defers it until the result is first touched, has moved the moment of the effect even though the value is identical. - **Concurrency safety.** The original may have been safe to call from many places at once. A wrapper holding a shared counter, store or log buffer has introduced state that all those callers now share. - **Cancellation and deadlines.** If callers can abandon a slow call, a wrapper that waits between retries, or that hands back a result already in flight, changes how long abandonment takes to take effect. - **The identity of the function value itself.** The wrapped function is a different value from the original. Anything that compared function values, keyed a registry by them, or unregistered by passing the same value back is now holding the wrong one. ## The honest example: counting effects Take a lookup that also writes an audit record every time it runs. Wrap it in caching. The signature is untouched, the returned values are identical, and the number of audit records has dropped from one per call to one per distinct key. Nothing in the type said 'runs exactly once per call', so nothing complains — and the audit trail is now wrong. This is the general rule in its sharpest form: **a wrapper that changes how often the inner function runs is only invisible if that function was pure.** The wrapper's correctness is a property of what it wraps, not of the wrapper alone. | Wrapper | Signature kept? | What it can still change | |---|---|---| | timing / logging | yes | almost nothing, unless it logs arguments that should not be recorded | | caching | yes | how many times the inner effect happens; freshness of the answer | | retry | yes | how many times the inner effect happens; how long a call may take | | batching / deduplication | yes | when the work happens; which caller's request actually ran | ## Keeping a wrapper invisible 1. **Decide what the inner function is allowed to be.** Say plainly that the caching wrapper is for functions without observable effects, and enforce it by where you wire it, not by hoping. 2. **Re-raise, never rewrite.** Observe, count and log failures; hand them onward exactly as they arrived. 3. **Keep the wrapper's state private and safe.** State captured by the returned function is shared by every caller of that wrapped function; treat it as concurrent from the first line. 4. **Pass cancellation and deadline information through** rather than starting a fresh budget inside the wrapper. 5. **Wire wrappers in one place** so that a reader looking for 'why did this run twice' finds the stack, rather than discovering it in a stack trace during an incident. ## When invisibility is not the goal Sometimes the change is the point: retry exists precisely to make one caller-visible call into several inner ones, and caching exists to skip inner work. The discipline is not 'never change behaviour' — it is to know exactly which property you are trading and to make sure the callers, or the wiring, agree to that trade. A retry wrapper over a non-repeatable operation is not a slower version of the original; it is a different operation, and the fact that it still fits the signature is what makes it dangerous.
- Which wrappers are safe to place over a function that has observable effects?Observers are: timing, counting and logging leave the effect count alone. The ones that change how often the inner function runs — caching, retry, deduplication, batching — are only safe where the effect is repeatable and skippable, or where the caller has explicitly accepted that change. The wrapper alone cannot tell; the property belongs to what it wraps.
- A wrapper keeps a small store shared by every caller. What does that add that the original did not have?Shared mutable state, and therefore a concurrency hazard the original may not have had. Two callers arriving at once can both read a missing entry, both do the work, and both write; or corrupt the store outright if it is not safe for concurrent use. The wrapper must provide the safety itself.
- How would you tell, in review, that a wrapper broke a caller?Look for the properties the type cannot express: does any failure change shape, does the inner function run a different number of times, does anything happen earlier or later than the call, and is any new state shared between callers. A wrapper that answers 'no' to all four is invisible; anything else is a behaviour change to justify.
saying these in an interview costs you the question
- Treats a matching signature as proof the wrapper changed nothing
- Wraps a function with observable effects in caching and calls it transparent
- Converts inner failures into neutral return values to simplify the wrapper
- Forgets that state captured by the wrapper is shared across all callers
- Applies retry to an operation that cannot safely be repeated