JavaScriptの言語仕様を決める委員会「TC39」の会議に参加してきたのトップ画像

JavaScriptの言語仕様を決める委員会「TC39」の会議に参加してきた

投稿日時:
maguroのアイコン

Deno Land Inc. / ソフトウェアエンジニア

maguro

Xアカウントリンク
「あの人も読んでる」略して「も読」。さまざまな寄稿者が最近気になった情報や話題をシェアする企画です。他のテックな人たちがどんな情報を追っているのか、ちょっと覗いてみませんか?

みなさんこんにちは。

「あの人も読んでる」、第23回目の投稿です。maguro(X @yusuktan)がお届けします。

いつもは記事や本、動画などを取り上げていますが、今回は少し趣向を変えて、先日僕が参加してきた会議の話をしようと思います。もはや「も読」(あの人も読んでる)なのか?という声が聞こえてきそうですが、会議で議題に上がっていたプロポーザルもいくつか紹介しますので、ご容赦ください。

TC39とは?

プログラミング言語のJavaScriptは、みなさんご存じかと思います。JavaScriptの言語そのものの仕様は、ECMAScriptという名前で標準化されています(fetchやDOMといったWeb APIは、また別の団体が策定しています)。ECMAはEcma International[1]という標準化団体の名前で、その技術委員会のひとつであるTC39がECMAScriptの仕様を策定しています。このTC39の第116回会議が、2026年9月29日から10月1日にかけて東京で開催されました。

TC39の会議には、Ecmaの会員企業や団体から派遣された delegate(代表者)と呼ばれる人たちが参加しています。ブラウザやJavaScriptエンジンの開発者をはじめ、さまざまな立場の人が集まって議論を交わします(Google、Apple、Mozilla、Bloomberg、Igalia、Cloudflareなど)。

どのような経緯でTC39の会議に参加することになったか

僕自身はdelegateではないのですが、会議を見学できるobserverという立場で参加することができました。会社の同僚のLeo(X @crowlKats)がdelegateを務めていて、「今回東京でやるから、よかったら参加してみない?」と声をかけてくれたのがきっかけです。

前々回の「も読」で、JavaScriptを使う側から仕様が決まる過程を眺める側へと見方が変わってきた話を書きましたが、僕にとってTC39はこれまで、公開される議事録やproposalのリポジトリを通して外から追うだけの存在でした。それが今回、実際にJavaScriptの仕様がつくられていく現場に立ち会えたことで、これまでGitHub上のテキストで追っていた議論が、人と人とのやりとりとして立体的に見えてきました。

TC39の会議はどう進むのか

まず前提として、TC39に持ち込まれる新機能の提案(proposal)は、Stage 0からStage 4までの段階を踏みながら仕様へと組み込まれていきます。詳しいルールはTC39のプロセス文書にまとまっていますが、ざっくり言うと次のような流れです。

  • Stage 0:アイデア段階。まだ委員会では正式に検討されていない
  • Stage 1:委員会が、その問題とありうる解決策を検討することに合意した段階
  • Stage 2:解決策の方向性が決まり、仕様の草案ができた段階。設計はまだ大きく変わりうる
  • Stage 2.7:仕様が完成してレビューも済み、あとはテストや実装による検証を待つ段階
  • Stage 3:実装が推奨される段階。各エンジンでの実装が進む
  • Stage 4:Test262[2]に通る2つの実装と、実際にshipされた実装での十分な実績が揃い、ECMAScript本体の仕様に取り込まれる段階

各Stageへ進むかどうかは、会議の場で議長がコンセンサスを確認しながら決めていきます。

会議はおよそ2カ月に1回のペースで開催されていて、議題は事前にtc39/agendasリポジトリで公開されます。今回の東京会議のアジェンダを見ても、各議題には5m、30m、120mといった持ち時間(timebox)がきっちり割り振られています。

実際に参加してまず驚いたのは、会議の進行がとても整然としていて、ほぼ時間通りに進んでいくことでした。発言したい参加者はTCQという専用のキュー管理ツールを使い、新しいトピックか、現在の話題への返信か、確認の質問か、あるいは議事進行上の割り込みかを選んで順番待ちに並び、議長はそのキューに沿って議事を進めていきます。会場の感触を確かめたい場面では、TCQのTemperature Checkという機能を使って、delegateたちから "Strong Positive" や "Unconvinced" といった反応を集め、その場で全体の温度感を可視化していました。

では議論まで杓子定規なのかというと全然そんなことはなく、delegateたちは積極的にキューへ並びますし、細かい仕様の挙動についてもどんどん意見が飛び交っていました。

特に印象に残っているのは、ある議題で議論が白熱して、何人かが互いに被せるように話し始めてしまった場面です。すかさず議長が "Please, let's not talk over each other" と割って入ると、場はすっと落ち着いて、またキューの順番どおりのやりとりに戻っていきました。成熟した会議だなと感じました。

会議の議事録は後日tc39/notesリポジトリで公開されます。TC39で普段どんなやりとりがされているのか気になる方は、ぜひ議事録を覗いてみてください。雰囲気がつかめると思います。

どういうプロポーザルが議論されたか

3日間の会議の中で、さまざまなトピックが議論されていました。今回はその中から、普段JavaScriptやTypeScriptを書いている人にも馴染み深そうで、僕自身もおもしろいと感じたものをいくつか紹介します。

Stage 4に到達したIteratorヘルパーたち

まずは、日常のコードにすぐ効いてきそうなIterator関連の提案からです。

イテレータは、next() を呼ぶたびに、次の値を { value: 1, done: false } のような形で1つずつ返すオブジェクトです(MDN: 反復処理プロトコル)。配列の values() や Map の keys()、ジェネレータ関数を呼び出したときの戻り値などがこれにあたります。配列と違って、値は要求されたタイミングで1つずつ取り出されるので、無限に続く列も表現できます。

ES2025[3]ではIterator Helpersが標準仕様に入り、map、filter、take、drop といったメソッドをイテレータに対して直接呼べるようになりました(MDN: Iterator)。今回の東京会議では、その流れを汲む3つの提案がStage 4を目指して議題に上がり、3つとも初日の9月29日にStage 4への昇格が決まりました。

Iterator.prototype.join

1つ目はIterator Joinです。その名の通り、Array.prototype.join のイテレータ版です。

たとえば Map のキーをカンマ区切りの文字列にしたいとき、これまでは一度配列に変換してから join を使うのが定番でしたが、Iterator Joinならその変換が要りません。

const m = new Map([["a", 1], ["b", 2], ["c", 3]]);

// これまで
Array.from(m.keys()).join(", "); // "a, b, c"

// Iterator Join
m.keys().join(", "); // "a, b, c"

Iterator.from(it).reduce((a, b) => a + sep + b) のように書く手もありますが、途中の文字列を毎回新しく確保するコストがかかるうえ、空のイテレータでは例外になってしまいます。かといって Array.from(it).join(sep) を使うと、今度はjoinするためだけに全体を配列へ変換するオーバーヘッドが生じます。提案のREADMEにはGitHub上のコード検索へのリンクとともに、この Array.from(...).join(...) というパターンが "very common" だと示されています。

Iterator.prototype.includes

2つ目はIterator Includesです。こちらも Array.prototype.includes のイテレータ版で、イテレータが特定の値を含むかどうかを調べるメソッドです。

function* gen() { yield 1; yield 3; }

gen().includes(1); // true
gen().includes(2); // false
gen().drop(1).includes(1); // false

イテレータに includes を足すだけの話にも見えますが、READMEの "design questions" の節を読むと、このメソッド1つのためにかなり細かいところまで検討されていることが分かります。たとえば値の比較方法には ===、==、Object.is(SameValue)、SameValueZeroの4つの候補がありましたが、Array.prototype.includes との一貫性を重視してSameValueZero[4]が選ばれました。

また、Array.prototype.includes の第2引数(fromIndex、探索を始める位置)にあたる引数についても、イテレータなら手前に drop[5] を挟めば済むので本来は不要なはずですが、Arrayのメソッドに慣れた人を混乱させないためにあえて受け付けるようにした、と説明されています(仕様上の名前は skippedElements で、先頭から読み飛ばす要素の数を指定します)。ただし、ここに負の整数(たとえば -1)を渡すと RangeError になります。配列なら -2 で「末尾から2番目」を指せますが、イテレータは先頭から順に値を取り出すため、最後まで読み切らないと全体の長さが分かりませんし、無限に続くイテレータもありうるので、「末尾から数えて何番目」という指定はできないのです。完全に同じインタフェースにはそろえられないという点も、READMEには率直に書かれています。

Iterator.prototype.chunks / Iterator.prototype.windows

3つ目はIterator Chunkingです。イテレータを一定の個数ずつまとめて取り出す chunks と、重なりを持たせながら取り出す windows(いわゆるスライディングウィンドウ)の2つのメソッドが追加されます。

const digits = () =>
  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].values();

Array.from(digits().chunks(3));
// [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [9] ]

Array.from(digits().windows(3));
// [ [0, 1, 2], [1, 2, 3], ..., [7, 8, 9] ]

RustやPythonを書いている方にはおなじみの操作だと思います。Rustのスライスにはまさに chunks と windows というメソッドがありますし、Pythonでも3.12で chunks に相当する itertools.batched が入りました。

この提案のREADMEには、さまざまな言語やLodash、RamdaといったJSライブラリにおける類似機能の比較表が載っています。サイズ0を指定したときの挙動ひとつ取っても言語ごとにバラバラで、興味深いです。

Async Iterator Helpers

Iteratorまわりでもうひとつ気になったのが、非同期イテレータ(MDN: AsyncIterator)版のヘルパーであるAsync Iterator Helpers(Stage 2)です。今回の会議では、提案全体の説明と未解決の論点の議論に120分という大きな枠が割り当てられていました。

会議で使われたスライドによると、この提案で追加されるのは以下のメソッドです(まだMDNにはページがありません)。

  • AsyncIterator.from
  • Iterator.prototype.toAsync
  • AsyncIterator.prototypeの
    • map / filter / flatMap
    • some / every / find / forEach / toArray
    • reduce
    • take / drop
    • buffered

buffered 以外は同期版のIterator Helpersとほぼ同じ顔ぶれです。一方で、先ほど紹介した join、includes、chunks、windows などは今回の提案には含まれておらず、後続の提案に回される可能性があるとのことでした。

同期版と一緒に入れてしまえばよかったのでは、と思うところですが、非同期版には同期版にない並行性(concurrency)の難しい設計課題があるため、別の提案として切り出された経緯があります。

const x = asyncIteratorOfUrls.map(u => fetch(u));

await Promise.all([
  x.next(),
  x.next(),
]);

このコードのように next() を2回続けて呼んだとき、2つの fetch は同時に走ってほしいところです。この提案の map、filter、flatMap では、呼び出す側が next() を複数回呼ぶことで並行に処理を進めるアプローチ(consumer-driven concurrency)をサポートしています。処理が並行して走っても、結果は元の順序どおりに返ってきます。

とはいえ、for await でループを回すような普通の書き方では next() は1回ずつしか呼ばれません。そこで登場するのが buffered(N) で、元のイテレータからN個のPromiseを先回りして並行に取り出しておき、元の順序どおりに渡してくれます。

const x = asyncIteratorOfUrls
  .map(u => fetch(u))
  .buffered(2);

for await (const item of x) {
  // ループの処理と並行して、2つのfetchが走る
}

スライドでは、まだ結果が返ってきていない next() の呼び出しが残っている状態で .return() が呼ばれたら、それらの結果をどう扱うか、といった未解決の論点が多数挙げられていました。仕様文の作成もこれからで、「flatMap だけで、仕様に存在する他のどのアルゴリズムよりも3倍以上複雑になると思う」と書かれていたのが印象的でした。

fetch を並行で投げるようなコードは普段からよく書くので、ここがどう決着するのかは気になるところです。まだStage 2ということもあり、細かい仕様は今後も議論されていくはずなので、引き続き動向を追っていこうと思います。

Composites:中身で比較できる Map / Set のキー

次に紹介するのは、9月30日にStage 2へ進んだCompositesです。

JavaScriptの Map や Set は、キーが同じかどうかを先ほど脚注で触れたSameValueZeroで判定します。プリミティブなら値の中身で比較されますが、オブジェクトは参照が一致しないと等しいとみなされません。そのため、プロパティと値が全く同じオブジェクトを2つつくって Set に入れても、別々の要素として扱われてしまいます。

const position1 = { x: 1, y: 4 };
const position2 = { x: 1, y: 4 };

new Set([position1, position2]).size; // 2

これを回避するために、JSON.stringify で文字列にしてからキーにする方法があります。ただ、このやり方だとオブジェクトのキーの順番が違うだけで別の文字列になってしまいますし、Set から値を取り出すときにはパースし直さなければなりません。

Compositesは、この問題を「中身が同じなら同じオブジェクトになる値」を導入することで解決しようとしています。

const pos1 = Composite({ x: 1, y: 4 });
const pos2 = Composite({ x: 1, y: 4 });
pos1 === pos2; // true

const positions = new Set([pos1, pos2]);
positions.size; // 1

Composite({ b: 2, a: 1 }) ===
  Composite({ a: 1, b: 2 }); // true

仕組みとしては、同じキーと値の組み合わせからつくられたCompositeは、常に同じオブジェクトとして返されます[6]。比較自体は通常のオブジェクトの同一性(参照の比較)になるので、=== がそのまま使えて、Map や Set の実装に手を加える必要もありません。

かつて提案されていたRecords & Tuplesは、新しい構文と新しいプリミティブ型でこの問題にも取り組もうとしていましたが、最終的に取り下げられました。Compositesはそのときの議論を踏まえ、新しい構文や型を追加する代わりに、通常のオブジェクトの仕組みの範囲内でこの問題を解決しようとしています。

名前を Composite にするのか、Object.intern のような別の名前にするのかも含めて、まだ議論が続いているところです。Stage 2は設計が大きく変わりうる段階なので、実際に使えるようになるまでには、もう少しかかりそうです。

JSON.parse Options

最後に、JSON.parse にオプション引数を追加するJSON.parse Optionsを紹介します。9月30日にStage 2.7へ進みました。

const obj = JSON.parse(
  '{ "one": { "two": 3 } }',
  { freeze: true },
);
Object.isFrozen(obj);     // true
Object.isFrozen(obj.one); // true

freeze: true を渡すと、パース結果がネストしたオブジェクトまで丸ごとfreeze(プロパティの追加・削除や値の変更ができない状態)になります(MDN: Object.freeze())。これまでの JSON.parse でも、第2引数に関数(reviverと呼ばれます)を渡せばパースされた各値に処理を挟めるので、JSON.parse(data, (key, value) => Object.freeze(value)) のように書けば近いことはできました(MDN: JSON.parse())。ただ、エンジン側でネイティブに実装すればずっと速くできる可能性がありますし、結果が確実にfreezeされていると静的に保証できる点もメリットとして挙げられています。

もうひとつのオプションが preferNullPrototype です。

const normal = JSON.parse('{ "a": 1 }');
// Object.prototypeから継承している
"toString" in normal;        // true

const bare = JSON.parse(
  '{ "a": 1 }',
  { preferNullPrototype: true },
);
Object.getPrototypeOf(bare); // null
"toString" in bare;          // false

通常のオブジェクトは Object.prototype を継承しているので、toString や hasOwnProperty といったプロパティを最初から持っています。一方、プロトタイプが null のオブジェクトは何のプロパティも継承していない、まっさらなオブジェクトです(MDN: null プロトタイプオブジェクト)。外部から受け取ったJSONを辞書のように扱うときに、意図せず継承プロパティと衝突する心配がありません。

この提案は、もともとRecords & Tuplesの一部として JSON.parseImmutable という名前で検討されていたものです。Records & Tuplesが取り下げられたことで、今の形につくり直されました。先ほどのCompositesもそうですが、一度取り下げられた提案のアイデアがこうして別の形で引き継がれていく様子が見えるのも、仕様策定のおもしろいところです。

おわりに

今回は、初めて参加したTC39の会議の様子と、Stage 4に到達したIterator関連の3つの提案、Async Iterator Helpers、Composites、そして JSON.parse Optionsを取り上げました。紹介しきれませんでしたが、ほかにもexport * fromでdefault exportも再エクスポートできるようにする提案や、fetchのキャンセルなどでおなじみのAbortControllerの一部を、ECMAScriptの仕様に取り込む提案、JSONの構文規格であるECMA-404の第3版が今年12月に発行予定であることの報告などもありました。

3日間を振り返ると、会議室の外での時間も印象に残っています。昼休みは、会議室の隣に用意された部屋でビュッフェ形式のランチでした。あちこちに自然と雑談の輪ができていて、さっきまでの議論の続きをざっくばらんに話している人たちもいれば、「AIをどれくらい使ってる?」「どのモデルが好き?」といった全然関係のない話題で盛り上がっている人たちもいました。こういう場があるからこそ、本番の会議での合意形成もうまく回るのかもしれません。

また次回、おすすめコンテンツを紹介していきます。お楽しみに!

maguroさんの「も読」過去記事

執筆者へのお便り

脚注
  1. もともとは1961年にスイスのジュネーブで設立された European Computer Manufacturers Association(欧州電子計算機工業会)の略で、活動が欧州の外へ広がったことから、1994年に現在のEcma Internationalという名称になりました(Ecmaの沿革)。 ↩

  2. Test262は、ECMAScriptの公式の適合性テストスイートです(tc39/test262)。各エンジンが仕様どおりに動くかを確かめるためのテストが集められていて、新しい機能のテストは、提案がStage 2.7の段階で書かれて追加されます。 ↩

  3. ECMAScriptの仕様は毎年1回新しい版が発行されていて、ES2025はその2025年版(ECMAScript 2025)のことです。その年の版に入るのは、例年3月の会議までにStage 4に到達した提案なので、今回Stage 4になった3つの提案は、ES2027に入る見込みです(TC39のプロセス文書)。 ↩

  4. SameValueZeroは、NaN同士を等しいとみなし、+0と-0も等しいとみなす比較です。たとえば [NaN].indexOf(NaN) のように indexOf を使うと、indexOf は === で比較するので -1 になりますが、[NaN].includes(NaN) のように includes を使うと、こちらはSameValueZeroによる比較がなされるため true になります。 ↩

  5. drop(n)は、ES2025で入ったIterator Helpersのメソッドの1つで、先頭からn個の値を読み飛ばした新しいイテレータを返します(MDN: Iterator.prototype.drop())。上のコード例のgen().drop(1)は、最初の1を読み飛ばして3だけを返すイテレータになるので、includes(1)がfalseになります。 ↩

  6. 同じ内容の値をメモリ上に1つだけ保持し、同じ値をつくろうとしたときには既存のインスタンスを返す手法は、interning(インターン化)と呼ばれます。同じ文字列を渡すと常に同じシンボルが返ってくる Symbol.for も、似た仕組みです。 ↩

20
20