Advanced Functions and Evaluation
Environment retention in long-lived closures, generator delegation and coroutine-like patterns, tail-call semantics versus real engine support, and the genuine risks of dynamic evaluation.
By the end of this lesson, you can
- Explain why a closure can retain more of its environment than the variables it visibly uses
- Use yield* to delegate part of a generator's work to another generator
- Explain why proper tail calls are specified but not a behavior JavaScript code can rely on in practice
- Explain why eval and new Function are not a meaningful sandbox boundary
Why it matters
Execution Model, Scope, and Closures
and Functional Techniques and Collection
Design covered closures and generators
at an everyday level. In long-lived systems, closures can retain more memory than expected;
generators compose into coroutine-like pipelines; and two commonly-cited “the engine will save you”
beliefs — proper tail calls and sandboxing with eval — don’t hold up the way many developers
assume.
Environment retention, precisely
A closure retains its entire lexical environment, not just the specific variables it visibly references — a subtlety that matters when one function in a shared scope is short-lived and another is long-lived:
function setup() {
const smallValue = 1;
const hugeArray = new Array(1_000_000).fill('data'); // large, unused by the returned function
return function () {
return smallValue; // only smallValue is referenced here...
};
}
const getSmallValue = setup();
// ...but depending on the engine's optimization, hugeArray may or may not be collectible,
// since both variables are conceptually part of the same retained environment record.
Generator delegation and coroutine-like patterns
yield* delegates part of a generator’s iteration to another iterable — including another
generator — forwarding values (and, for a full delegation, return values and thrown errors) through
transparently:
function* inner() {
yield 1;
yield 2;
return 'inner done';
}
function* outer() {
const result = yield* inner(); // delegates entirely to inner()
console.log(result); // "inner done" — inner's return value, captured here
yield 3;
}
console.log([...outer()]); // [1, 2, 3] — inner's yields pass through transparentlyThis composability is what makes generators useful as coroutine-like building blocks — a larger, staged process (parse, then validate, then transform) can be built from smaller generators each delegating to the next, pausable and resumable as a unit, without any of them needing to know about the others’ internals.
Tail calls: specified, but not reliable
The specification defines proper tail call optimization: a function call in tail position (the very last action before returning) can reuse the caller’s stack frame instead of growing the stack, making certain recursive patterns safe from a stack overflow regardless of recursion depth.
'use strict';
function sumTo(n, acc = 0) {
if (n === 0) return acc;
return sumTo(n - 1, acc + n); // tail position — the call IS the return value, nothing after it
}
Dynamic evaluation and the sandbox misconception
eval() and new Function() both execute a string as JavaScript code at runtime:
eval('console.log("hi")'); // runs in the current scope (direct eval) — can read/write local variables
const fn = new Function('a', 'b', 'return a + b'); // runs in the global scope, not the local one
Beyond the safety concern, eval/new Function also block a wide range of engine optimizations for
the surrounding code (since the engine can no longer statically determine what variables might be
read or modified), and dynamically-imported module specifiers built from untrusted strings carry a
comparable risk to the classic eval case.
Check your understanding
Or reveal the answer without checking
Answer:[2, 4, 6]
doubledRange manually iterates range's values with for...of (not yield*, since it transforms each value) and yields each one doubled: 1→2, 2→4, 3→6.
Or reveal the answer without checking
Answer:V8 (which Node uses) does not implement proper tail calls, so deep recursion will overflow the stack in production despite the spec allowing the optimization
Proper tail calls are specified but V8 has not implemented them, unlike Safari's JavaScriptCore. Code relying on this optimization for stack safety will overflow in Node (and Chrome) despite being spec-compliant — an explicit loop or accumulator pattern is the reliable choice.
Practice: warm-up
Predict the output, then check:
function* letters() {
yield 'a';
yield 'b';
}
function* combined() {
yield 1;
yield* letters();
yield 2;
}
console.log([...combined()]);
Stuck? Reveal one hint at a time.
Hint 1
yield* fully delegates to another iterable, forwarding each of its yielded values in place, in order.
Hint 2
The surrounding yields (1 and 2) still happen in their own written order, with the delegated values inserted exactly where yield* appears.
Reveal the output
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
[1, "a", "b", 2]Practice: apply it
Explain, in your own words, why the following is not a safe way to run untrusted code, even though it might appear contained at first glance:
function runUntrusted(code) {
const sandbox = { result: null };
try {
eval(code);
} catch {
// swallow errors
}
return sandbox.result;
}
Stuck? Reveal one hint at a time.
Hint 1
Direct eval runs in the calling function's own scope — it can read and write sandbox and anything else runUntrusted can see, not just interact through some restricted surface.
Hint 2
It can also reach far beyond sandbox entirely — the global object, constructors, anything reachable through the prototype chain from any value it can access.
Reveal the explanation
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
Direct eval runs with full access to runUntrusted’s own scope (including sandbox itself,
read/write) and everything else reachable from that execution context — the global object, every
built-in constructor, anything walkable via the prototype chain. Untrusted code could reassign
global state, redefine built-ins, or simply ignore sandbox and do something else entirely; nothing
about this structure restricts what it can reach. A genuine sandbox requires an actual isolation
boundary (a separate process, a realm with no shared references) — no amount of surrounding scope
structure inside the same realm provides real containment.
Summary
- A closure retains its entire lexical environment, not just the variables it visibly uses — an engine may optimize away truly unreferenced large data, but that’s an optimization, not a guarantee; restructure scope deliberately when it matters.
yield*delegates a generator’s iteration to another iterable, forwarding values (and the delegate’s return value) transparently — the basis for composing generators into staged, coroutine-like pipelines.- Proper tail call optimization is specified but implemented only by Safari’s JavaScriptCore, not V8 or SpiderMonkey — don’t rely on it for stack safety in most real deployments.
eval/new Functionprovide no meaningful sandbox boundary inside a single realm — genuine isolation requires a real boundary (a separate process or realm), not a scoping trick.