How it works
When a CallExpression is evaluated and the callee is an identifier reference named eval (not a property reference), the engine checks SameValue(func, %eval%). If it matches, the call is direct, and the spec calls PerformEval(arg, strictCaller, true). Otherwise it is an ordinary call: the eval function itself calls PerformEval(x, false, false). That is why o.eval(x), (0, eval)(x) and eval?.(x) are indirect: none is a plain identifier call. Shadowing is also covered: if eval is a local variable that isn't %eval%, the call is a normal call to that function.
PerformEval then:
- Returns the argument if it isn't a string.
- Calls the host hook
HostEnsureCanCompileStrings. Browsers use it to apply CSP and Trusted Types; by default it does nothing.
- For direct eval, checks context:
new.target, super.x and super() are a SyntaxError unless the caller is in a function, a method or a derived constructor.
- Parses the text as a Script. Parse errors throw
SyntaxError.
- Chooses environments. Direct: a new declarative environment inside the caller's lexical environment, with the caller's variable environment. Indirect: a new declarative environment inside the realm's global environment, with the global one as variable environment. If the eval code is strict (strict caller or its own directive), the variable environment is the new one, so
var stays inside.
- Runs
EvalDeclarationInstantiation, then evaluates the body.
CreateDynamicFunction (behind Function) builds source text from the parameter and body strings, parses it, and creates the function in the realm's global environment. It calls the same host hook, with direct set to false.
Edge cases
Strictness comes from different places in each form:
function strictDemo() {
'use strict';
eval('var inner = 1');
console.log(typeof inner);
(0, eval)('var fromIndirect = 1');
console.log(typeof fromIndirect);
}
strictDemo();
// → undefined
// → number
The direct eval inherits strict mode, so inner stays inside. The indirect eval ignores the caller's strictness and creates a global variable. In Node.js 24, a bare eval('var v = 1') in an ES module also leaves v undeclared outside, since module code is strict.
Other results, run in Node.js 24 as CommonJS: eval('({a:1})') is { a: 1 }, eval('{a:1}') is 1, eval(42) is 42, eval('let q = 1') declares nothing outside, (0, eval)('typeof module') is 'undefined' because CommonJS wrapper variables aren't global, and new Function('return this')() === globalThis is true even when called from a module.
CSP: with script-src and no 'unsafe-eval', direct eval, indirect eval and Function() throw EvalError and report a violation. String arguments to setTimeout/setInterval are blocked too, but quietly: the call returns 0, the code never runs, and only the violation report shows it. I checked all four in Chrome 155 under script-src 'self'. MDN also describes a way to allow string compilation under Trusted Types: pass TrustedScript values created by a policy and add 'trusted-types-eval' (not tested).
Real-world scenario
A calculator that evaluates a user-typed formula should parse it with a small expression parser, not eval: eval('1; fetch("/steal")') is also a valid formula. The same goes for any config, template or plugin string that crosses a trust boundary. If the code is yours and static, a normal function or import() does the job without a string.
Trade-offs
Direct eval is the only way to run a string in your own scope, and almost nothing needs that. new Function or indirect eval give a smaller, predictable scope, but still need trusted input and a CSP that allows them. A parser or a lookup table beats any of them for untrusted input. For isolation from your own globals, a separate realm is a different tool: node:vm contexts and iframes exist today (see the realms question), while ShadowRealm is a TC39 proposal at Stage 2.7 and was not available in Node.js 24 (typeof ShadowRealm is 'undefined').