ES1 to ES2027: What Each Version Fixed and the TC39 Stage 0-4 Process
The Decorators proposal was introduced by Yehuda Katz in 2015, widely considered "obviously useful and easy to implement" — yet it only reached Stage 3 in 2023, after two complete design overhauls. This is not procrastination. It is TC39's deliberately slow-paced protection mechanism working exactly as intended.
🔹 Level 1 · What You Need to Know
When you encounter a syntax feature, the first question should be: "What year was this added? Does my target environment support it?" The table below is the complete version timeline. Milestone releases are in bold.
Complete ECMAScript Version Timeline
| Year | Version | 3 Most Important Additions | Milestone |
|---|---|---|---|
| 1997 | ES1 | Language foundation, typeof, new |
Starting point |
| 1998 | ES2 | Editorial corrections | No new language features |
| 1999 | ES3 | Regular expressions, try/catch, do-while |
10-year foundation |
| 2009 | ES5 | Strict mode, Object.defineProperty, JSON |
Cleared 10 years of debt |
| 2015 | ES6/ES2015 | let/const, arrow fns, class, Promise, Map/Set, Symbol, modules |
Largest single update |
| 2016 | ES2016 | Array.prototype.includes, ** exponentiation |
Annual releases begin |
| 2017 | ES2017 | async/await, Object.entries/values, shared memory |
Async revolution |
| 2018 | ES2018 | Async iteration, Promise.finally, Rest/Spread props |
|
| 2019 | ES2019 | Array.flat, Object.fromEntries, optional catch binding |
|
| 2020 | ES2020 | ?. optional chaining, ?? nullish coalescing, BigInt, globalThis, Promise.allSettled |
|
| 2021 | ES2021 | String.replaceAll, Promise.any, logical assignment ??= &&= ||= |
|
| 2022 | ES2022 | Class private fields #, top-level await, Array.at(), Object.hasOwn |
|
| 2023 | ES2023 | Array.findLast, Array.toSorted (immutable sort), Hashbang |
|
| 2024 | ES2024 | Object.groupBy, Promise.withResolvers, ArrayBuffer.resize |
|
| 2025 | ES2025 | Iterator.prototype helpers, RegExp.escape, Set collection operations |
|
| 2026 | ES2026 | Float16Array, Math.sumPrecise |
Draft stage |
| 2027 | ES2027 | Temporal (date/time API), Signals | Stage 3 candidate |
Quick memory aid: ES3 (1999) lays the foundation, ES5 (2009) clears the debt, ES2015 (2015) is the giant leap, ES2017 brings
async/await, ES2020 brings optional chaining, ES2022 brings private fields.
6 Syntax Features You Always Wonder About
// Arrow functions → ES2015 (2015)
const add = (a, b) => a + b;
// async/await → ES2017 (2017)
async function fetchData() {
const data = await fetch('/api/data');
return data.json();
}
// Optional chaining → ES2020 (2020)
const name = user?.profile?.name ?? 'Anonymous';
// Nullish coalescing → ES2020 (2020)
const timeout = config.timeout ?? 3000;
// Class private fields → ES2022 (2022)
class Counter {
#count = 0; // private: # is syntax, not a naming convention
increment() { this.#count++; }
get value() { return this.#count; }
}
// Top-level await → ES2022 (2022) (ES modules only)
// config.js
const config = await fetch('/api/config').then(r => r.json());
export default config;
🔸 Level 2 · How It Really Works
Design Motivation Behind Each Milestone
ES5 (2009): Clearing 10 years of ES3 debt
ES3 shipped in 1999. For the next 10 years (2000–2009), the ECMAScript specification received almost no updates. Three reasons:
- IE dominated the browser market (peak share exceeded 90%); Microsoft had no incentive to advance the spec.
- ES4's failure in 2008 consumed most of the community's political capital.
- The industry still positioned JavaScript as a "simple scripting language," not a production-grade platform.
ES5's three core fixes:
// Fix 1: Strict mode — removes the most dangerous features
'use strict';
// Behaviors that silently fail in sloppy mode but throw in strict mode:
x = 10; // undeclared variable → ReferenceError
delete Object.prototype; // deleting non-deletable → TypeError
function f(a, a) { } // duplicate parameter → SyntaxError
with (obj) { } // with statement → SyntaxError
// Fix 2: Object.defineProperty — property-level metaprogramming
// ES3 had no way to control enumerability, writability, or configurability
const person = {};
Object.defineProperty(person, 'name', {
value: 'Alice',
writable: false,
enumerable: true,
configurable: false
});
// Fix 3: Native JSON — previously required Douglas Crockford's json2.js
const obj = JSON.parse('{"name":"Alice","age":30}');
const str = JSON.stringify(obj, null, 2); // third arg = indent level
ES2015 (2015): The eruption after a 7-year freeze
After ES4 failed, TC39 spent 6 years (2009–2015) building ES6/ES2015. The sheer number of features reflects a backlog of ideas that had been discussed during the ES4 era but never shipped:
ES2015 feature origins:
┌──────────────────────────────────────────────────────────────┐
│ Feature │ Design Reference │ Problem Solved │
├──────────────────────┼─────────────────────┼────────────────┤
│ let/const │ Block scope │ var hoisting │
│ Arrow functions │ CoffeeScript │ this confusion │
│ class │ (prototype sugar) │ Verbose inherit│
│ Promise │ Promises/A+ spec │ Callback hell │
│ import/export │ CommonJS/AMD │ Global pollution│
│ Destructuring │ Python/Haskell │ Verbose extract│
│ Template literals │ Other languages │ String concat │
│ Generators │ Python │ Lazy seq/coro │
│ Symbol │ (new type) │ Unique keys │
│ Proxy │ Python __getattr__ │ Metaprogramming│
│ Map/Set │ Java/Python │ Object-as-map │
│ WeakMap/WeakRef │ (new) │ Memory leaks │
│ Iteration protocol │ (unified interface) │ for...of basis │
└──────────────────────┴─────────────────────┴────────────────┘
ES2017 (2017): What async/await really is
async/await is syntactic sugar over Promises, which in turn rely on Generator mechanics. Understanding this helps debug complex async code:
// async/await desugared to Generator + Promise (simplified):
// Original async function:
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`); // pause point
const data = await response.json(); // pause point
return data;
}
// Equivalent Generator + Promise version:
function fetchUser(id) {
return new Promise((resolve, reject) => {
const gen = (function* () {
try {
const response = yield fetch(`/api/users/${id}`);
const data = yield response.json();
resolve(data);
} catch (err) {
reject(err);
}
})();
function step(value) {
const result = gen.next(value);
if (result.done) return;
result.value.then(step, (err) => gen.throw(err));
}
step();
});
}
// This is why try/catch works inside async functions:
// gen.throw() converts a rejected promise into a catchable exception
ES2020: The design decisions behind optional chaining and nullish coalescing
// Optional chaining ?. — solves nested property access null-pointer crashes
// Legacy (ES5 era, seen throughout jQuery codebases)
var name = user && user.profile && user.profile.name || 'Anonymous';
// ES2020
const name = user?.profile?.name ?? 'Anonymous';
// Full ?. syntax (not just property access)
const result1 = obj?.method?.(); // optional method call
const result2 = arr?.[0]; // optional index access
const result3 = obj?.['key']; // optional dynamic property access
// ?? vs || (a common confusion point)
const a = 0 || 'default'; // 'default' — 0 is falsy
const b = 0 ?? 'default'; // 0 — ?? only triggers on null/undefined
const c = '' || 'default'; // 'default'
const d = '' ?? 'default'; // '' — empty string is not null/undefined
ES2022: The design philosophy behind class private fields #
class BankAccount {
#balance = 0;
#owner;
constructor(owner, initialBalance) {
this.#owner = owner;
this.#balance = initialBalance;
}
deposit(amount) {
if (amount <= 0) throw new Error('Amount must be positive');
this.#balance += amount;
return this;
}
get balance() { return this.#balance; }
}
const account = new BankAccount('Alice', 1000);
account.deposit(500);
console.log(account.balance); // 1500
console.log(account.#balance); // SyntaxError: inaccessible from outside
// Why # instead of the _ naming convention?
// _balance is just a convention — the engine doesn't enforce it
// #balance is enforced by the engine: accessing it outside the class is a SyntaxError
The TC39 Proposal Process: Stage 0-4
TC39 proposal lifecycle:
┌─────────────────────────────┐
│ Anyone can submit Stage 0 │
└─────────────────────────────┘
Stage 0 (Strawman / Idea)
├─ Entry: a TC39 member believes it is worth tracking
├─ Docs: informal description
├─ Typical duration: weeks to months
└─ Outcome: advances to Stage 1 or is shelved
Stage 1 (Proposal / Formal Proposal)
├─ Entry: at least one TC39 member agrees to Champion
├─ Docs: problem statement, high-level API, examples
├─ Typical duration: months to years
└─ Outcome: advances, is revised, or is withdrawn
Stage 2 (Draft)
├─ Entry: committee believes the feature belongs in the spec
├─ Docs: complete spec text draft (may still be imprecise)
├─ Typical duration: months to years (highest-risk phase)
└─ Outcome: advances to Stage 3 or regresses to Stage 1
Stage 3 (Candidate)
├─ Entry: spec text complete, at least two independent implementations
├─ Docs: complete precise spec text, designated reviewers signed off
├─ Typical duration: months (waiting for more implementations/feedback)
└─ Outcome: advances to Stage 4 (rarely regresses)
Stage 4 (Finished)
├─ Entry: two spec-conformant implementations, full test suite, editor approval
├─ Docs: Test262 test suite, formal spec PR
└─ Result: included in next annual ECMAScript release
Real case: Pipeline Operator stuck in Stage 2
The pipeline operator (|>) is the canonical "stuck in Stage 2" example. The core use case is simple:
// Goal: make function chaining more readable
// Without pipeline:
const result = JSON.stringify(Array.from(new Set([1,2,3,2,1])));
// With pipeline (F# style — one of the proposals):
const result = [1,2,3,2,1]
|> new Set(%) // % is "topic reference" (current value in pipe)
|> Array.from(%)
|> JSON.stringify(%);
Why has it been stuck for nearly a decade (2015–2023+)?
Pipeline Operator factions:
┌──────────────────────────────────────────────────────────┐
│ Camp 1: F# style (concise) │
│ value |> fn │
│ value |> fn(%, arg2) // % = topic reference │
│ │
│ Camp 2: Hack style (more flexible) │
│ value |> fn(%) │
│ value |> %.method() │
│ │
│ Camp 3: Smart mix (most complex) │
│ value |> fn // auto-infers single arg │
│ value |> fn(%, arg2) // explicit multi-arg │
└──────────────────────────────────────────────────────────┘
Three incompatible semantics; TC39 spent years unable to reach consensus.
2023: Hack style is the leading candidate, still in Stage 2.
🔺 Level 3 · What the Spec Says
TC39 Charter and "How New Features Are Added"
The formal Ecma TC39 Charter specifies the committee's decision mechanism:
"TC39 is a technical committee of Ecma International. It is responsible for editing and maintaining the ECMA-262 specification, which defines the ECMAScript programming language. TC39 operates by consensus: a proposal is accepted when no member objects to it, rather than by majority vote."
Key point: consensus, not majority vote. This explains why a feature supported by most members can stall if even one or two members raise strong objections.
From the TC39 Process Document on acceptance criteria:
Stage 3 Entry Criteria:
- The spec text must be complete and reviewed by designated reviewers and all ECMAScript editors.
- All semantics, API, and syntax are finalized.
- The ECMAScript specification editor has signed off on the current spec text.
Stage 4 Entry Criteria:
- Two compatible implementations that pass the acceptance tests (which are part of Test262)
- A pull request to tc39/ecma262 with the integrated spec text
- All ECMAScript editors have signed off on the pull request
The importance of Test262
Test262 is ECMAScript's official conformance test suite, hosted at github.com/tc39/test262. Stage 4 requires a complete Test262 test contribution. These same tests are used by V8, SpiderMonkey, and JavaScriptCore to verify their implementations.
"Don't Break the Web" in spec language
ES5 Section 16 (Errors) and Annex B encode web compatibility requirements. ES2015 expanded Annex B with more entries, explicitly marking which features are "historical legacy preserved for web compatibility":
"The following features are specified for web browser implementations only. It is not part of the core ECMAScript specification. Conforming implementations of ECMAScript that are not web browsers are not required to implement these features."
This means Node.js is technically not required to implement Annex B content — but in practice implements most of it, because many npm packages assume these features exist.
💎 Level 4 · Edge Cases and Traps
Trap 1: The 9-Year Journey of the Decorators Proposal (2015–2023)
The Decorators proposal is the most complex proposal in JavaScript history, requiring two complete design overhauls:
Decorators timeline:
2015 → Stage 0: Yehuda Katz (Ember core team) submits
Based on Python's decorator design
TypeScript 1.5 ships an early experimental implementation
↓
2016 → Stage 1: enters formal proposal
↓
2018 → Stage 2: initial spec draft
Problem found: semantic conflict with the "class fields" proposal
↓
2019 → First complete redesign
"Legacy decorators" (TypeScript/Babel's shipped version)
vs "Static decorators" (new semantics, incompatible with old)
Committee cannot reach consensus
↓
2021 → Second complete redesign
"ECMAScript Decorators" (a third semantic model)
Compatibility with TypeScript's experimental version is abandoned
↓
2022 → Stage 3: finally reaches Candidate stage
↓
2023 → V8 12.0, SpiderMonkey begin implementation
TypeScript 5.0 implements new-style decorators
The three incompatible decorator semantics:
// TypeScript legacy (experimental) decorators — widely used 2015–2022
// tsconfig: "experimentalDecorators": true
function log(target, key, descriptor) {
const original = descriptor.value;
descriptor.value = function(...args) {
console.log(`calling ${key}`);
return original.apply(this, args);
};
return descriptor;
}
class Service {
@log // legacy decorator syntax
getData() { return fetchData(); }
}
// ES2022 Stage 3 new-style decorators — incompatible with the above!
function log(fn, ctx) { // different parameter signature
return function(...args) {
console.log(`calling ${ctx.name}`);
return fn.apply(this, args);
};
}
class Service {
@log // same syntax, different behavior!
getData() { return fetchData(); }
}
// If your project relies on old TypeScript decorators,
// migrating to new-style decorators requires rewriting every decorator.
Trap 2: Array.prototype.flatten → Array.prototype.flat Naming Crisis
In 2019, Array.prototype.flatten reached Stage 3 and implementations shipped. Then the "MooTools Crisis" hit:
// 1. MooTools (framework released in 2006, still widely used in legacy apps)
// had already added its own flatten to Array.prototype:
Array.prototype.flatten = function() {
// MooTools implementation — different behavior from the spec draft
return this.reduce((acc, val) => acc.concat(val), []);
};
// 2. If native flatten were added, it would overwrite MooTools's method,
// silently changing the behavior of flatten in every MooTools site.
// 3. Impact assessment:
// - npm analysis found millions of sites still using MooTools
// - Browsers are required to keep these sites working
// 4. TC39 resolution: rename the method to flat
[1, [2, [3]]].flat(); // [1, 2, [3]] — default: 1 level deep
[1, [2, [3]]].flat(Infinity); // [1, 2, 3] — flatten all levels
// flatMap was kept as-is (not flattenMap)
[1, 2, 3].flatMap(x => [x, x * 2]); // [1, 2, 2, 4, 3, 6]
Lesson from the MooTools Crisis: even namespace-level choices (Array.prototype) are constrained by legacy frameworks. This is the price of the Web Compatibility principle.
Trap 3: How "Don't Break the Web" Blocked Obvious Fixes
Case 1: Array.prototype.contains → Array.prototype.includes
The method was originally named contains and reached Stage 3 in 2014. Then MooTools was found to have added contains to both String.prototype and Array.prototype with incompatible behavior. The name was changed to includes.
// Now it is includes (ES2016)
[1, 2, 3].includes(2); // true
[1, 2, NaN].includes(NaN); // true — uses SameValueZero, handles NaN correctly
[1, 2, NaN].indexOf(NaN); // -1 — uses strict equality, NaN !== NaN
Case 2: HTML comment syntax forcibly retained
// This still works in modern JS engines:
<!-- This is an HTML-style comment
console.log('hello');
// --> This is also a comment
// Historical reason: in 1995, browsers that didn't support JS would
// parse <script> content as HTML, so HTML comments hid the JS code.
// Removing this feature today would break some legacy sites.
// Annex B keeps it.
Case 3: The 0-prefixed octal literal (must be kept)
// ES3-era octal literal (still valid in non-strict mode)
console.log(010); // 8 (octal), not 10!
console.log(011); // 9
// Banned in strict mode
'use strict';
console.log(010); // SyntaxError: Octal literals are not allowed in strict mode
// ES2015 unambiguous octal syntax:
console.log(0o10); // 8
console.log(0o11); // 9
// Why does the old syntax still work in non-strict mode?
// Countless legacy script files contain this syntax without strict mode.
// Removing it would silently change their numeric values — high-risk.
Chapter Summary
-
ECMAScript has gone through roughly 30 revisions from ES1 (1997) to ES2025. ES3 (1999), ES5 (2009), ES2015, and ES2017 are the most important milestones. ES2015's large size reflects 7 years of pent-up demand after ES4's failure.
-
TC39 uses consensus, not majority voting. A proposal moves from Stage 0 (idea) to Stage 4 (shipped) only after at least two independent spec-conformant implementations and a complete Test262 test suite are in place.
-
The Decorators proposal took 9 years (2015–2023) to reach Stage 3 because of semantic conflicts with the class fields proposal and because TypeScript had already shipped an incompatible implementation. Two full redesigns were required, abandoning compatibility with the existing implementation.
-
Array.prototype.flat(notflatten) is the clearest example of Web Compatibility cost: MooTools had defined a same-named but behaviorally different method onArray.prototype, forcing TC39 to rename the spec method. The same story played out withincludes(originallycontains). -
async/awaitis a Generator + Promise combination under the hood: eachawaitpause point corresponds to agen.next()call, and errors propagate viagen.throw(), which is whytry/catchcan catch errors thrown byawaitexpressions.