Syntax, early errors & static semantics
Read ECMAScript grammar rules, trace early errors and static semantics, and see why some JavaScript scripts fail before their first statement runs.
- 01Read a grammar ruleIdentify a production, its optional parts, parameters, lookahead, and restricted line breaks.
- 02Locate a rejectionTell parsing and early errors from errors caused by executing an otherwise valid script.
- 03Use static semanticsExplain how BoundNames, VarDeclaredNames, and parameter checks describe written code.
What syntax checks before code runs
Syntactic grammar describes how JavaScript tokens fit into expressions, statements, scripts, and modules. The previous Lexical grammar & ASI lesson made those tokens. This lesson reads the rules that combine them. An engine may use a different parser design, but it must accept and reject the same language.
const cart = { tea: 3 };console.log(cart.tea);Line 1 creates cart with a tea value of 3. Line 2 reads the property and prints 3. Before that read can happen, the source must form a valid script and satisfy any rules checked from the source itself.
Early errors reject a script or module before its first evaluation. Static semantics are rules about parsed source, including early errors and operations that collect written names.
This follows How to read ECMAScript: instead of tracing a property read, we now ask whether the source may run at all. For engine implementation and syntax trees, see Parsing and ASTs and Lazy parsing. Here we read the language specification, not an engine's parser.
Read a grammar production
A production names a source shape. Its left side is a nonterminal, a name for a group of possible shapes. Its right side contains literal tokens or other nonterminals. A syntactic production has one colon; the lexical grammar used two.
let price = 3;console.log(price);Line 1 declares price and initializes it to 3. Line 2 prints 3. In the spec, names like BindingIdentifier and Initializer describe parts of a declaration; they are not functions to call.
VariableStatement : var VariableDeclarationList ;
VariableDeclaration : BindingIdentifier Initializer[+In]opt
WhileStatement : while ( Expression ) StatementRead the last line aloud: while, an opening parenthesis, an expression, a closing parenthesis, then a statement. Each is one piece of a valid while statement. The declaration line uses opt to allow an initializer to be absent. The displayed lines are selected productions, not a full grammar or runnable JavaScript.
| Mark | Meaning | Use |
|---|---|---|
Statement : ... | One colon: syntactic production | A shape of tokens that can form a statement. |
Identifier :: ... | Two colons: lexical production | A shape of source characters that forms a token. |
Initializer[+In]opt | Parameter plus optional symbol | The initializer may be absent; if present, In is enabled. |
[lookahead not in ...] | Restricted next tokens | This alternative cannot start with certain tokens. |
[no LineTerminator here] | Restricted line break | That production cannot cross a line terminator there. |
The grammar's goal is Script or Module. The same token spelling can face different restrictions depending on that goal. Parsing produces parse nodes, a spec way to describe spans of source, not a required engine data structure. The spec's syntactic grammar explains that boundary.
Grammar parameters change which shapes are allowed
A grammatical parameter chooses a version of a rule for its context. [Yield] matters inside generators; [Await] matters in async functions and modules; [In] helps distinguish the in operator from a for header. [Return] allows return statements in function bodies, not at ordinary script top level.
function* orders() { const order = yield "tea"; console.log(order);}const list = orders();console.log(list.next().value);list.next("cake");Line 1 defines a generator. Line 2 yields tea and waits. Line 3 will log the value sent back later. Line 5 makes an iterator, line 6 prints tea, and line 7 resumes it with cake, which line 3 prints. The output is tea then cake.
const cart = { tea: 3 };console.log("tea" in cart);Line 1 makes a cart with a tea key. Line 2 checks whether the key exists and prints true. The spec uses +In to permit that operator in this grammar position and ~In to remove it where it would conflict with part of a for statement. A ?In reference passes the current setting down.
The Yield and Await settings are also about identifiers, not only operators. A generator cannot bind yield as its own local name, and an async function cannot bind await. Module code also prohibits await as an identifier. The spec's identifier early errors give the exact contexts.
console.log("before");
async function total() {
let await = 3;
}console.log("before");
function* total() {
let yield = 3;
}console.log("before");
const await = 3;In each failed example, line 1 would log before, but no log happens: the source cannot start. The module example needs a module goal, so it is not an Edit & run script; the test checks it as an .mjs file. These restrictions depend on context, not on a global list of forbidden spellings.
Optional parts and lookahead
The suffix opt means that a grammar part may be omitted. It is not JavaScript optional chaining. A lookahead restriction tells a production which next tokens it may or may not see. The next token matters because some forms, such as an expression statement starting with {, could be read another way.
const order = 3;console.log(order);Line 1 creates order with value 3. Line 2 has a call expression used as a statement and prints 3. A call can begin this kind of statement. The lookahead rule blocks some other starts so the grammar does not confuse a block or declaration with an expression statement.
ExpressionStatement : [lookahead not in { {, function, class, let [ }] Expression ;This line is a teaching paraphrase, not an exact copy of the production. Read it as: before using this expression-statement alternative, check the next tokens against the excluded starts. The exact expression-statement syntax is in the current spec.
A register has a space for a mark beside each name. A blank mark does not remove the name, and the next name begins a new row.
- In real life: A name starts one row
- In JavaScript: A token begins one source form
- In real life: A mark may be blank
- In JavaScript: An opt grammar part may be absent
- In real life: The next row has another name
- In JavaScript: Lookahead checks which token comes next
Where the analogy stops: A parser follows formal grammar rules; a teacher can infer intent from handwriting.
An early error stops the whole script
A successful parse is not enough. The spec applies early error rules to the parsed script or module before evaluation. An invalid lexical name can therefore prevent a log before the offending declaration from running. This is about one source unit, not a claim that every other script on a page is stopped forever.
let price = 3;console.log(price);Line 1 binds price to 3. Line 2 prints 3. Now keep the first log but add two let declarations of the same name in the same scope.
console.log("before");let price = 3;let price = 4;console.log("after");Line 1 asks to print before, but nothing is printed. Lines 2 and 3 declare the same lexical name; the whole script is rejected before line 1 executes. Line 4 never runs either. The test writes this source to a file and runs Node, asserting a SyntaxError and empty standard output rather than trusting the console panel alone.
console.log("before");try { missingPrice();} catch (error) { console.log(error.name);}Line 1 prints before. Line 3 tries to call a missing name. That lookup fails during execution. Line 4 catches the error and line 5 prints ReferenceError. Both logs appear: before, then ReferenceError. Unlike a compile-time SyntaxError, a runtime error can be handled by this try/catch.
| Case | Why | Observable effect |
|---|---|---|
| Grammar mismatch | The tokens cannot form the selected Script or Module goal. | The source is rejected before that unit runs. |
| Early error | The shape parses, but a static rule rejects it. | The source is rejected before that unit runs. |
| Runtime error | Valid source starts evaluating, then an operation throws. | Earlier effects in the same script may already have happened. |
function describeEarlyCheck(kind) { return kind === "duplicate" ? "reject before running" : "run until the call fails";}console.log(describeEarlyCheck("duplicate"));reject before running
This teaching model says: reject before running. The examples above show the actual JavaScript difference.
Switch only the case selector, then Reset to return to the duplicate case. The selector uses a labeled teaching model; it never compiles user input. This distinction matters when a build reports a syntax problem even though a log before the bad line exists.
One initial shape can cover two readings
A cover grammar accepts an initial token shape that might have more than one use. Later the surrounding context requires it to match a more specific supplemental grammar. CoverParenthesizedExpressionAndArrowParameterList is the spec name for the parentheses that might group an expression or form the parameters of an arrow function.
const add = (price) => price + 2;console.log(add(3));Line 1 defines add; the parentheses hold the parameter price because an arrow follows. Line 2 calls it with 3 and prints 5. The arrow selects the arrow-parameter reading, not a call to two grammars.
const price = (3);console.log(price);Line 1 groups the value 3; no arrow follows. Line 2 prints 3. A parser cannot just call every parenthesized form a parameter list: context decides which supplemental rule must fit. The grouping rule requires a parenthesized expression.
console.log("before");
const price = (3,);
console.log(price);Line 1 never prints. Line 2 looks close to an arrow parameter list with a trailing comma, but no arrow follows. As an expression in these parentheses, (3,) does not fit the supplemental grammar; line 3 is never reached. That is a pre-execution SyntaxError, not a value equal to 3.
Replay instrumented teaching code, not the specification's parser.
script
function chooseParenthesizedForm(hasArrow) { return hasArrow ? "arrow parameters" : "grouped expression";}The replay calls the lesson's tiny choice function and records its result. It models why context matters, not how a browser parses tokens. Real cover grammars use the complete source form and apply early errors to the required reading.
Static semantics ask questions about written names
BoundNames is a syntax-directed operation that collects names introduced by a binding pattern. A syntax-directed operation follows the production a parse node matched; it does not read a current runtime variable. In a destructuring declaration, the object property name and the new local name can differ.
const { tea: price } = { tea: 3 };console.log(price);Line 1 takes the tea property, whose value is 3, and binds it under the local name price. Line 2 prints 3. For this pattern, BoundNames includes price, not tea. The operation examines source names; it does not return the runtime number.
var price = 3;var price = 4;console.log(price);Line 1 initializes price to 3. Line 2 updates the same var binding to 4. Line 3 prints 4. VarDeclaredNames collects names of var-scoped declarations; LexicallyDeclaredNames collects names from lexical declarations such as let and const. Collecting a name does not itself mean a duplicate is illegal; an early error rule decides which collisions to reject.
console.log("before");
const { tea: price } = { tea: 3 };
let price = 4;Line 1 still prints nothing. Line 2 binds price through the pattern, and line 3 also binds price. Static name checks find the collision before the script evaluates. A linter may give a friendlier message, but the language rule does not depend on the linter.
| Operation | What it inspects | This lesson's example |
|---|---|---|
BoundNames | Names bound by a declaration or pattern | const {tea: price} = cart binds price, not tea. |
LexicallyDeclaredNames | Names from lexical declarations in a statement list | Two let price declarations collide in one scope. |
VarDeclaredNames | Names from var-scoped declarations | Two var price declarations can coexist. |
IsSimpleParameterList | Whether every parameter is a plain binding identifier | price = 3 makes a list non-simple. |
ContainsUseStrict | Whether a function body has a use strict directive | A body directive with non-simple parameters is rejected. |
Replay instrumented teaching code, not a JavaScript engine debugger.
script
function hasDuplicateName(names) { return new Set(names).size !== names.length;}console.log(hasDuplicateName(names));The replay's Set is a deliberately small teaching model of comparing written names. It is not the spec's BoundNames algorithm and is not an engine parser dump. The function and its edge cases are tested separately.
Function heads have extra early-error rules
IsSimpleParameterList asks whether all parameters are plain names. A default value, rest parameter, or destructuring pattern makes the list non-simple. ContainsUseStrict checks for a "use strict" directive in the function body. The spec combines these facts when validating function declarations.
function total(price) { return price + 2;}console.log(total(3));Line 1 declares total with one plain parameter, price. Line 2 adds 2. Line 4 passes 3 and prints 5. This parameter list is simple.
function total(price = 3) { return price + 2;}console.log(total());Line 1 gives price a default of 3, making the list non-simple. Line 2 adds 2; line 4 supplies no argument, so it prints 5. Default parameters themselves are valid. The combination below is the problem.
console.log("before");
function total(price = 3) {
"use strict";
return price + 2;
}Line 1 would print before. Line 2 has a default parameter; line 3 contains a body directive. Together they cause an early SyntaxError. Do not confuse a body directive with a function placed inside an already strict module: the specific error here is about the directive in that body and a non-simple list.
console.log("before");
const total = (price, price) => price;console.log("before");
function total(price, price) {
"use strict";
return price;
}console.log("before");
function total(price = 3, price) {
return price;
}Each failed source has a line 1 log that never happens. Arrow parameters must be distinct; strict function bodies forbid duplicates; non-simple parameter lists also require distinct names even without a body directive. A legacy non-strict simple function can differ, so do not teach "all duplicate parameters are invalid" as a blanket rule. Read the current function rules for the exact contexts.
[no LineTerminator here] is a real restriction
A restricted production forbids a line terminator at a marked position. After return, a line break can produce a bare return through automatic semicolon insertion (ASI). After throw, the same break cannot make a valid throw statement. These are different outcomes.
function receipt() { return 3;}console.log(receipt());Line 1 starts a function. Line 2 returns without a value because the next line cannot be part of that return. Line 3 is a separate unreachable expression. Line 5 calls the function and prints undefined, not 3.
console.log("before");
throw
new Error("no order");Line 1 cannot print before. Line 2 has no expression before the line break; line 3 cannot repair it. Parsing fails with SyntaxError before evaluation begins. This is why adding semicolons mechanically is not a universal fix for newline bugs.
const total = (price)
=> price + 2;
console.log(total(3));Line 1 closes the arrow parameters. Line 2 starts with => after a line break, which this production forbids. Line 3 never logs. The spec's notation for restricted productions makes the difference between ordinary white space and a forbidden line break explicit.
Check a checkout script before blaming a function
Suppose a checkout page stops rendering. Reduce its JavaScript to one valid read before inspecting application state. An early error anywhere in the same script prevents the first useful log, while a valid script can log and then fail at runtime.
const cart = { tea: 3 };console.log(cart.tea);Line 1 creates a cart whose tea costs 3. Line 2 reads the field and prints 3. Next, a destructuring declaration can name that field price without declaring the field name itself.
const cart = { tea: 3 };const { tea: price } = cart;console.log(price);Line 1 creates the cart. Line 2 takes its tea value into the local price. Line 3 prints 3. Adding another let price in this scope would reject the script before any line runs, not merely replace the price.
More early errors are worth recognizing in reviews. An invalid assignment target such as 3 = 4, a break to an unknown label, and a regex literal with repeated g flags cannot start this script. The following sources deliberately fail; their first logs are probes for the no-output rule.
console.log("before");
3 = 4;console.log("before");
for (const order of ["tea"]) {
break missingLabel;
}console.log("before");
const pattern = /tea/gg;Line 1 of each failed source is the same probe. In the assignment case, line 2 has no valid target. In the label case, line 3 has no matching label; in the regex case, line 2 repeats the flag. Each fails before any log. For AST and parser implementation details, use Parsing and ASTs; this lesson asks what the language requires.
throwfollowed by a line break- Two
let pricedeclarations in one scope - A regex literal with
/tea/gg - Call
missingPrice()after a log - Find the bound name in
{tea: price} - Ask whether
price = 3is a simple parameter
Sort each case by the kind of question it answers. Read the reason on each card.
Common misconceptions
Syntax and static semantics both inspect source, but they answer different questions. Grammar asks whether the tokens fit; early errors ask whether an otherwise parsed form is allowed. A JavaScript error object alone is not enough to determine when it arose.
- “The log before bad code still runs.” Not when the same script has an early error.
- “Every SyntaxError is caught by try/catch in that source.” An early error prevents that catch from starting.
- “Any parentheses before an arrow are ordinary grouping.” An arrow head must satisfy its supplemental parameter grammar.
- “Static semantics are a slow engine phase.” They are specification rules, not a required implementation schedule.
- “Every newline inserts a semicolon.” Return may split; a newline after throw makes the source invalid.
- “Any duplicate name is invalid.” The relevant declaration kind, scope, and function context matter.
Compare the let error with the valid repeated var example, and compare the newline after return with the one after throw. The interesting question is not just whether the source contains a familiar keyword; it is which production and early-error rule applies there.
| Case | Why | Observable effect |
|---|---|---|
| Grammar mismatch | The tokens cannot form the selected Script or Module goal. | The source is rejected before that unit runs. |
| Early error | The shape parses, but a static rule rejects it. | The source is rejected before that unit runs. |
| Runtime error | Valid source starts evaluating, then an operation throws. | Earlier effects in the same script may already have happened. |
Practice exercises
Predict the visible output first, then name the rule. Each starter has only a few lines; use Edit & run for complete valid examples and read deliberate failure examples without trying to recover them in the same script.
A receipt stores a price in parentheses. Predict the log, then say why this is grouping and not arrow parameters.
const price = (3);
console.log(price);const price = (3);
console.log(price);It prints 3. The grouping does not change the value.
A checkout script logs before trying a missing function. Is this an early or runtime error? Include the first printed word in your answer.
console.log("before");
try {
missingPrice();
} catch (error) {
console.log(error.name);
}console.log("before");
try {
missingPrice();
} catch (error) {
console.log(error.name);
}This is a runtime error; before prints first, then the catch prints ReferenceError.
A price updater uses an arrow. What more specific grammar must its parenthesized head cover? Test the completed function too.
const add = (price) => price + 2;
console.log(add(3));const add = (price) => price + 2;
console.log(add(3));The parentheses are arrow parameters. The call prints 5.
A cart has a tea property. Name the binding introduced by the destructuring pattern; do not answer with the property key.
const { tea: price } = { tea: 3 };
console.log(price);const { tea: price } = { tea: 3 };
console.log(price);BoundNames finds price. The source then prints 3 from that binding.
The developer expects a log before the function declaration. Can this script start? State which two static questions decide the answer.
console.log("before");
function total(price = 3) {
"use strict";
return price + 2;
}No. ContainsUseStrict is true while IsSimpleParameterList is false, so the script has an early SyntaxError and even before does not print.
A real checkout page failed after someone added a second let price. Keep a single destructured price binding and predict the working output. Explain why the repaired script can run.
const cart = { tea: 3 };
const { tea: price } = cart;
console.log(price);const cart = { tea: 3 };
const { tea: price } = cart;
console.log(price);It prints 3. The pattern binds price once, and the script starts successfully.
Check your understanding
For each code question, decide first whether the source can run at all. Then predict logs in order. For spec questions, separate the grammar's shape from named static rules and runtime behaviour.
Question 1 of 8What does one colon after a nonterminal signal?
Choose an answer to see the explanation.
Question 2 of 8What does this grouped expression print?
Read the code, then predictconst price = (3); console.log(price);Choose an answer to see the explanation.
Question 3 of 8How many lines from this script run?
Read the code, then predictconsole.log("before"); let price = 3; let price = 4;Choose an answer to see the explanation.
Question 4 of 8Which name does BoundNames find in
const {tea: price} = cart?Choose an answer to see the explanation.
Question 5 of 8Why does
(price) => price + 2use a cover grammar?Choose an answer to see the explanation.
Question 6 of 8What does this function print?
Read the code, then predictfunction receipt() { return 3; } console.log(receipt());Choose an answer to see the explanation.
Question 7 of 8What does this valid script print?
Read the code, then predictconsole.log("before"); try { missingPrice(); } catch (error) { console.log(error.name); }Choose an answer to see the explanation.
Question 8 of 8Can a function with
price = 3parameters contain a use strict directive in its body?Choose an answer to see the explanation.
Key takeaways
A useful reading sequence is: find the grammar production, check its parameters and restrictions, then read the static rules. Only after that should you trace what evaluation prints or throws.
- Syntactic productions combine tokens; grammatical parameters choose the context-specific forms.
- Optional parts, lookahead, and restricted line breaks change which source is accepted.
- Cover grammars allow an initial shape and then require a valid supplemental reading.
- Early errors reject the whole script or module before its first evaluation.
- BoundNames, VarDeclaredNames, LexicallyDeclaredNames, IsSimpleParameterList, and ContainsUseStrict describe source, not runtime values.
- A runtime ReferenceError can follow an earlier log; an early SyntaxError cannot.
Remember the one-liner.
Grammar builds a source shape; static rules decide whether that shape is allowed before it runs.
Coming next: Evaluation order. That lesson follows the work an accepted expression does from left to right. Meanwhile, Parsing and ASTs covers the engine view.