skip to content

ScopedValue has no set() method — how do you provide a different value to part of a call, and why is this design chosen?

level: seniorimportance: should knowfreq 30%

answer

  1. no set() — rebind by nesting where()
  2. inner binding shadows outer for its dynamic extent only
  3. stack discipline: push on enter, pop (restore) on exit
  4. immutability kills action-at-a-distance
  5. immutable => safe to share by reference with subtasks

basics

~20 s

You can't change a bound value; instead you open a new nested binding with where() around the inner code. The inner value applies only inside that nested block and the outer value is restored when it ends. This keeps data flow predictable and stack-structured.

solid answer

~50 s

ScopedValue is intentionally immutable: there is no set(). To give a sub-region of a call a different value, you nest another binding — ScopedValue.where(KEY, newValue).run(inner) — inside the outer scope. Within inner (and anything it calls) KEY.get() returns newValue; the moment inner returns, the previous binding is automatically restored. So bindings behave like a stack: push on enter, pop on exit. This rebinding is confined to the dynamic extent of the inner scope and never affects sibling code. The design is deliberate. Mutability is what makes ThreadLocal hard to reason about — a deep method could change a value other code later reads, action at a distance. By forbidding set(), ScopedValue guarantees that within any scope the value is constant, every change is visible at an explicit binding site, and bindings unwind cleanly with the call stack, which also makes them cheap to share by reference with forked subtasks.

code

java · 13 lines
java
static final ScopedValue<String> TENANT = ScopedValue.newInstance();

ScopedValue.where(TENANT, "acme").run(() -> {
    assert TENANT.get().equals("acme");

    // No set(): to use a different value, open a nested binding.
    ScopedValue.where(TENANT, "globex").run(() -> {
        assert TENANT.get().equals("globex");   // shadows outer
    });

    // Inner scope ended -> previous binding automatically restored.
    assert TENANT.get().equals("acme");
});

go deeper

for a junior

Knows you cannot change a scoped value and that nesting a new where() gives inner code a different value.

for a middle

Explains shadowing and automatic restoration on inner-scope exit, and that there is no set().

for a senior

Articulates the three rationales (no action-at-a-distance, exception-safe stack-discipline cleanup, safe share-by-reference) and the down-only data flow.

for a principal

Connects immutability to the broader design (structured concurrency inheritance, performance, API ergonomics) and can advise when a mutable-holder workaround is or isn't appropriate.

## The starting point A `ScopedValue<T>` is bound for the dynamic extent of a `where(KEY, value).run(task)` call. Crucially, there is **no `KEY.set(...)`** — once bound, the value is fixed for that scope. New learners coming from `ThreadLocal` (which has `set()`) often ask: "how do I change it partway through?" The answer reframes the question. ## Rebinding via nesting You don't *change* a value; you **shadow** it with a fresh binding around a smaller region: ```java static final ScopedValue<String> TENANT = ScopedValue.newInstance(); ScopedValue.where(TENANT, "acme").run(() -> { System.out.println(TENANT.get()); // acme ScopedValue.where(TENANT, "globex").run(() -> { System.out.println(TENANT.get()); // globex (inner scope) }); System.out.println(TENANT.get()); // acme again (restored) }); ``` The inner `where` introduces a new binding that **shadows** the outer one only for the dynamic extent of the inner `run`. When the inner `run` returns, the binding stack pops and the outer value (`acme`) is automatically back in effect. This is exactly like nested lexical variable shadowing, but along the *call stack* (dynamic scope) rather than the source text. ## Why immutable? — three reasons **1. Eliminates action-at-a-distance.** With a mutable `ThreadLocal`, any method reachable from a call could do `tl.set(x)` and silently alter what a caller reads afterward. Bugs from this are notoriously hard to trace because the mutation point and the read point are far apart. With ScopedValue, the value is constant within a scope, and *every* value change corresponds to a visible `where(...)` at a known site. Data flow becomes local and auditable. **2. Stack discipline = automatic, exception-safe cleanup.** Because a value can only be introduced by entering a scope and can never be mutated in place, bindings form a strict stack that unwinds with the call stack. The runtime restores the previous binding on scope exit — including when the inner block throws — with no `finally`/`remove()` from you. Correctness is structural, not a matter of remembering cleanup. **3. Cheap sharing with subtasks.** Immutability means a binding can be **shared by reference** rather than copied. In structured concurrency, a parent that forks many virtual-thread subtasks lets them all read the same binding without per-thread copies (contrast `InheritableThreadLocal`, which copies to each child). Mutability would forbid this safe sharing because a subtask could change a value other subtasks see. ## Practical consequences - Treat a ScopedValue as a *constant within its scope*. If you find yourself wishing you could mutate it, that usually signals you actually want a *nested scope* (a genuinely different sub-context) or a different tool (e.g., a mutable holder object you bind once and whose contents you change — but be deliberate, that reintroduces mutability of the referenced state, not of the binding). - Rebinding is local: sibling code outside the inner scope is unaffected. There is no way for the inner scope to push a value *outward* to its caller — information only flows *down* the call stack. ## Mental model ScopedValue bindings are like dynamically-scoped `final` constants on a stack: each scope can introduce its own constant value, nested scopes can shadow it, and exiting a scope pops back to the enclosing value — always, automatically, even on exceptions.

  • Can an inner nested scope change the value seen by its caller after it returns?
    No. Information only flows down the call stack. When the inner scope exits, its binding pops and the caller sees the original value; there is no upward propagation.
  • If you bind a ScopedValue to a mutable object (e.g., a list), can callees mutate it?
    Yes — the binding (which reference is bound) is immutable, but the referenced object's contents are not. Mutating bound state is discouraged because it reintroduces the action-at-a-distance you were avoiding; bind immutable values.

saying these in an interview costs you the question

  • Looking for a set()/update() method — none exists by design
  • Thinking an inner rebinding permanently changes the value for the whole scope — it only shadows within the nested extent
  • Assuming a nested scope can pass a value back up to its caller
  • Believing immutability of the binding means the referenced object is also immutable

context