3 ポイント 投稿者 GN⁺ 2024-07-26 | 1件のコメント | WhatsAppで共有
  • Node.js PR #53725 がマージされ、--experimental-strip-types フラグで TypeScript ファイルの実行を実験的にサポートするようになった
  • この機能は TypeScript ソースから 型構文を除去して JavaScript ソースへ変換し、変換過程で型チェックは行わない
  • 初期実装では Enumnamespaces のような TypeScript 専用機能の変換は行わないため、型の除去だけで JavaScript になるコードに限定される
  • 実装には @swc/wasm-typescript が選ばれた。理由は Rust や Go を Node.js ツールチェーンに追加しなくてよいという 単純さ
  • 議論では、ESM の必須ファイル拡張子ルール、.js import が実際には .ts ファイルを指す TypeScript の慣習、ソースマップとエラースタック位置のコストが主な制約として扱われた

--experimental-strip-types の動作方式

  • --experimental-strip-types を設定すると、Node.js が TypeScript ファイルを実行できる
  • Node.js は TypeScript ソースコードを JavaScript ソースコードへ トランスパイルする
  • トランスパイル過程では 型チェックは行わず、型情報は破棄される
  • 例は次のとおり
const foo: string = "foo";
const foo = "foo";

型の除去だけを選んだ理由と初期の制限

  • この PR の核心は、TypeScript 構文のうち 型構文の除去である
  • 他のランタイムでは、enum のように JavaScript にはない TypeScript 専用機能を JavaScript に変換することもある
  • この PR の初期実装では、そのような変換は行わない
    • Enum は使用不可
    • namespaces は使用不可
    • その他の TypeScript 専用機能も変換対象ではない
  • 実装の詳細と制限については、PR の変更内容に含まれる typescript.md を参照するよう案内されている

実装ツールとして @swc/wasm-typescript を選択

  • @swc/wasm-typescript小さなパッケージで、wasm ファイルとそれをバインドする JavaScript ファイルで構成されている
  • 他のツールには、Rust や Go を Node.js ツールチェーンに追加する必要があるものがあった
  • SWC は Deno でも同じ目的で使われており、PR 作成者はこれを 実証済みの選択肢と見ている
  • 将来的には、この機能が ネイティブレイヤーで実装される可能性があると見ている
  • この PR は nodejs/loaders#217 を参照している

import 解決と拡張子の議論

  • import foo from "./foo" のような 拡張子なしの import はサポートされない
  • Node.js ESM には mandatory file extensions ルールがあり、拡張子なし import の非サポートは意図された動作として扱われている
  • TypeScript のモジュールコードベースでは、実際のファイルが .ts でも import 文には .js 拡張子を書くことが一般的だという意見が出た
    • 例: import foo from './foo.js'
    • 実際のファイルシステムには ./foo.js がなく、./foo.ts だけがある場合
  • PR 作成者は、この問題は今後の反復作業で扱える可能性があり、拡張子の推測には追加のファイルシステム確認とヒューリスティックが必要だと見ている
    • ./foo.js がなければ ./foo.ts を確認
    • ./foo.ts がなければ ./foo.js を確認
  • この議論は nodejs/loaders#208 で追跡されている

ソース位置、ソースマップ、エラースタック

  • レビューでは、エラースタックの 正確なソース位置が短期的に必要だという意見が出た
  • 可能な方法の一つは、型の除去過程でソース位置を変えず、ソースマップ生成とマッピングを避けること
  • ソースマップにはメモリ使用量と性能面でコストがあり、単純ではないという制約が言及された
  • PR 作成者は、SWC がすでにソースマップをサポートしているため、これを有効化するフラグを追加できると見ている
  • 除去された構文を空白で置き換え、元のエラースタックを変えないオプションを SWC に要望することもできると見ている

安定化前に残る設計上の論点

  • TypeScript サポートは二つの軸に分かれる
    • 型構文の除去
    • .ts ファイルを受け入れるよう解決ルールを調整
  • 解決ルールの変更は既存の TypeScript ローダーと衝突する可能性があるという懸念が出た
  • PR 作成者は、この PR には解決ルールのオーバーライドはなく、ファイル拡張子が必要だと回答した
  • allowImportingTsExtensions によって import './file.ts' 形式をサポートでき、この方式は Deno とも合うという意見が示された
  • 同じソースファイルを直接実行し、ビルドツールで JavaScript 出力も作るには、file.tsfile.js に変える責任はビルドツールが担えると見ている

マージと後続作業

  • PR は 2024年7月24日に nodejs:main へマージされた
  • 変更規模は 8コミット、89ファイル、2,190行追加と25行削除
  • semver-minorexperimentalmoduleesmnotable-change などのラベルが付けられた
  • Web サイトの TypeScript 案内ページでこの機能に言及するかについて議論があり、関連する更新 PR もリンクされた
  • 配布メタデータに、Node バイナリが型の除去に使用する TypeScript バージョンを含める案も後続イシューとして議論された

1件のコメント

 
GN⁺ 2024-07-26
Hacker News のコメント
  • TypeScript の型削除は、TypeScript 構文なしには不可能です。型削除はトークン単位の作業ではなく、TypeScript 構文は変わり続けています。
    たとえば foo < bar & baz > ( x ) は、TypeScript 1.5 では bar&baz が有効な型式ではなかったため (foo (x)) のようにパースされましたが、交差型演算子が追加された後は foo<(bar & baz)>(x) としてパースされ、foo(x) へと曖昧性が解消されました。
    最新の TypeScript 機能を使い続けるには JS にコンパイルするか、Node のバージョンを常に最新に保つ必要があり、Node LTS にとどまることを好むチームにとっては受け入れがたい妥協になり得ます。

    • Node チームもこの問題をすでに考慮しているようです。Node は npm をバンドルしつつ別途アップグレードできる前例があるので、TypeScript トランスフォーマーも同様にできるという議論があります。
      コアには安定版を置き、TypeScript が新機能や破壊的変更を出したり、ユーザーが新しい実験的機能を使いたい場合は NPM から別途アップグレードできるようにする方式です。
      こうすれば、ユーザーを固定せずに Node.js リリースの 3 年間のライフサイクル中、特定の TypeScript バージョンをサポートできます: https://github.com/nodejs/loaders/issues/217
    • 型削除という観点での構文は、不安定だった期間よりも安定していた TypeScript バージョンのほうがはるかに多いです。1.5 までさかのぼったのも、2.x 以降はかなり安定していたからです。
      最後の大きな構文変更はおそらく 2.8 の 条件付き型で、型の位置に三項 if 演算子に似た構文が入った時でしょう。型チェックまで行うなら型モデルは 2.x 以降かなり変わりましたが、構文は概ね安定しており、TypeScript の革新も大半は型モデルと型推論の側にありました。
      まだ初期段階の Stage 1 ですが、型チェックではなく型削除を目的とした TypeScript の型構文の相当部分を標準化しようとする試みもあります: https://github.com/tc39/proposal-type-annotations
    • コンパイラも一緒に更新する必要があります。結局「TypeScript のバージョンを Node.js のバージョンと独立して上げられない」と表現できますが、おそらく大きな問題ではないでしょう。
    • 不等号記号を山括弧のように使うのは、本当に失敗だった気がします。
    • 最新の TypeScript を常に追いかけ、新しい言語機能を使うチームは、最新の Node バージョンも追いかける可能性が高そうです。経験上、TypeScript のアップグレードは Node のアップグレードよりも、新しいエラーのために移行や修正が必要になることが多かったです。
      最新の V8 や Node 機能にあまり関心がなく LTS にとどまるチームは、最新の TypeScript 機能にもそれほど関心がない可能性が高いでしょう。
  • Node.js が TypeScript ファイルを直接実行できるなら、TypeScript コンパイラは型削除や JavaScript 変換の代わりに型チェッカーとしてだけ使えるようになります。Python で型チェッカーが型を検査してそのまま残し、インタプリタは型ヒントを無視する方式に似ています。
    興味深いことに Python では、同じ型ヒント構文を使いながらも意味論は異なる人気の型チェッカーが複数生まれました。一方 JavaScript では、TypeScript が事実上唯一の人気の型チェッカーになったようです。
    Python では型をチェックせず、ソースに型だけを書いて、より便利なコメント構文のように使う場合もあると聞きました。Node.js が型の無視をサポートすれば、JavaScript でもそのようなやり方が可能になります。

    • Facebook の Flow は数年前には JavaScript 側でかなり重要でしたが、今では TypeScript がかなり明確に勝ったように見えます。
    • 型チェックをしなくても、IDE が型ヒントを利用して補完などを改善できるので、一部の場所に型を追加することには依然として意味があります。
    • 今でも JSDoc で型を付け、TypeScript で検証すればコンパイルなしで可能です。ビルドは速くなり、魔法のような処理やコメント以外の削除作業なしに、どこでも動くコードが得られます。
      個人的に JSDoc 使用で最大の不便はインポート構文でしたが、TypeScript 5.5 以降で変わったため、今では問題ではなくなりました。
    • Node.js だけが JavaScript ランタイムではありません。すべてのブラウザが TypeScript を直接実行できるようになるまでは、ブラウザ向けには TS を JS にコンパイルする必要があります。
      ただし一部のバンドラはすでに SWC のような非公式コンパイラを使ってこの作業を行っており、Node もこの機能で SWC を試験中です。
      型ヒントは単なるコメントだけでなく、その名のとおり IDE がよりよい補完を表示するためのヒントでもあります。
    • この方向の EcmaScript 提案があります: https://github.com/tc39/proposal-type-annotations
      これは言語仕様の一部になるべきだと思います。
  • この機能がフラグの裏側ではなくデフォルトになったとき、NPM エコシステムがどう反応するのか気になる。コントリビューターが NPM モジュールを配布する際に、今後も CJS と ESM のバージョンをビルドするのか、それとも package.jsonengine: nodejs >= 25 だけを付けてビルド段階を省くのかは分からない
    個人的には、TS のソースを持つ NPM モジュールが dist/.cjs の配布をやめれば、デバッガーや console.log をどこに入れればよいかが明確になって、むしろ良いと思う。NPM コントリビューターの立場でも、ビルド段階をなくす誘惑は大きいはずだ
    ただし、依存関係の一つでも TS ファイルを配布すると、「全員が TS ファイルを受け取る」という前提が急速に広がり得る。Node.js がこれをオプトインの実験フラグの外に出した瞬間、コミュニティ全体が利用者に TS ファイルの受け入れを暗黙に期待するようになりそうだ
    そうなると数か月以内に Firefox と Safari も受け入れるよう圧力を受け、すべての JS コンパイラが TS の型注釈を捨てざるを得なくなるだろう
    個人的には、それでも構わない。TS を使う側でも、何らかの架空の JS ユーザーをサポートするために、TS コードを JS と .d.ts に変換する段階を NPM モジュールに入れているからだ。とはいえ Node が .ts ファイルを受け入れると、その変換段階に気づかないまま取り除けてしまうので、NPM 公開者が js/d.ts ファイルの配布をやめても壊れたことに気づかない事態を防ぐ仕組みが気になる

    • Ryan Dahl はこの問題を解決するために JSR という新しいパッケージレジストリを作っている
      ビルド段階なしで TypeScript コードをアップロードでき、他の開発者がインストールすると、変換済みの JavaScript ではなく元の TypeScript ソースを見られるようにする方式だ
    • node_modules 内の .ts ファイル実行はサポートしない。エコシステムを壊さないための主要な制約の一つだ
    • メンテナンス中の古い TypeScript ライブラリは npm 向けに .cjs.mjs に変換しているが、おそらく 3 種類すべてのバージョンを配布することになると思う
      新しくゼロから書くプロジェクトなら TypeScript だけを配布し、変換はしないかもしれない
      追記: PR 作成者であり Node.js 技術運営委員会メンバーと思われる satanacchio の兄弟コメントによると、TS サポートはトップレベル項目向けで、node_modules 内のライブラリには適用されないとのこと
    • 自分のソースコードである .ts を npm に配布したいが、TypeScript チームは tsconfig の問題と性能の問題のため、これに非常に反対していた。それでも期待はしている
  • いつか Node が JS でこれらの型をランタイムでリフレクションできるようにするかもしれない
    そうなれば非常に大きな利点になる。Python では型をリフレクションできるため、pydantic のような優れたツールが型から検査を生成できる
    単一の標準表記だけで、型検査、ランタイムデータ検証、API 生成、API ドキュメント生成を得られる
    現在の JS では、Zod のようなツールが const mySchema = z.string(); のように、TypeScript がすでに行っていることを実質的に再発明しなければならない

    • Zod の z.string() は TypeScript のような型安全性だけを提供するわけではない。TypeScript はコンパイル時の型検査を提供し、Zod はランタイム検証とパースを追加する
      z.string()mySchema をパースと検証が可能な関数型スキーマにする。mySchema.parse("some data") は成功し、mySchema.parse(321) は例外を投げる
      ランタイム検証とプロセス内チェックが必要な場所で使ったことがあるが、かなりうまく動作したし、const A = z.string(); type A = z.infer; // string のように型も取り出せる
      つまり Zod で先に型を定義し、そこから型を推論すれば、コンパイル時とランタイムの型検査を両方得られる。ただし、動きの速い小さなコードベースには過剰かもしれないが、プロセス内検証が必要な状況ではかなり有用で、作業量も大きくない
    • 最近の TypeScript チームの焦点は JavaScript 言語との整合にかなり向いており、ランタイム型のような新機能はほぼ除外されたか、少なくとも TC39 の JavaScript 型提案の後ろに回された状態だ。クラス構造の外にある変数にデコレーターを使う問題も似ていた
      ただし TypeScript はプラグインを許可している。あまり使われてはいないが、結果の JavaScript に変換される別の機能を言語に追加できる
      ランタイム型に関連するプラグインとして Typia があり、assert(myValue) のようなガードで TypeScript の型シグネチャをランタイムで使えるようにしてくれる。この関数呼び出しを横取りし、変換後の JavaScript に渡された変数の性質を検査する網羅的な if 文を生成する
      今後 4〜6 年以内に言語の一部になるとは思わないが、今日でもこうしたことを可能にするライブラリはすでにある
    • JS がいつか型検査を追加するなら、TypeScript を選ばないでほしい
      実際には健全な型システムが必要だが、TS は意図的に健全ではない。TS のように悪いコーディング慣行を許容せず、プログラムを高速にできる設計を強制する型システムが必要だ。Hindley-Milner 型システムが必要だ
      モジュールを型付きコードにしたいなら、"use type" を追加する形でよい。このモードでは型強制変換のような悪い部分を禁止し、オブジェクトの形や値の型を変えたり、任意の値を混ぜた配列を作ったりするなど、性能を損なう要素を防ぐべきだ
      型なしモジュールから入ってくるデータは変換されるか、変換できなければエラーを投げる。そうすればコンパイラは、より強い型保証と途中で諦めるリスクがない状態で、型付きコードを深く最適化できる
    • これは、Node が例外が ValueError のサブクラスか、オブジェクトが SomeClass のインスタンスかを判別できるという意味なのか気になる。TS 初心者なので、配列、オブジェクト、数値、文字列以外の型は JS にはなく、Zod や型ガード関数は「信じてくれ」程度の普通のオブジェクトを返すものだと思っていた
  • Bunの開発者体験はこの領域ではかなり前例のないレベルで、今ではほとんどのユースケースがカバーされているか、bun runで実行スクリプトを書くときにBunがクラッシュしなくなっている
    一方でNodeは、importで拡張子を要求しないように設定することもできず、tscがコンパイル結果に.js拡張子を自動で付けるようにもできないため、バンドラーを追加する必要がある
    ネイティブTypeScriptサポートはこの些細な不便をかなり解消するだろうが、安定化してもユーザー体験や性能がBunに匹敵するとは想像しにくい

    • 拡張子は要求されるべき。ローカルディスクのようにネットワーク上でパス検索はできないし、ブラウザのようなネットワーク接続ランタイムはJavaScriptにおいて非常に重要
    • Bunはかなり好きだが、Denoのほうが今でも成熟していて安定しており、機能も多い。安定したWorkerやHTTP/2などもあり、ユースケースによってはV8がJSCより速い場合もある
      開発者体験とツールも最高水準。Denoは型チェックもでき、記憶ではTSCをバンドルしている
      Bunは話題性が大きいが、真剣な作業には現時点ではDenoのほうが明らかに良い選択肢。それでもBunのビジョンと実行力は印象的で、開発者にとって良いことだ
    • 数カ月おきにBunを2回試したが、Windowsでbun installの実行に失敗し、一度もまともに動かなかった: https://github.com/search?q=repo%3Aoven-sh%2Fbun+bun+install...
    • 「前例のない」というのは、Denoを除けばそうだろう
    • Bunはまだあまりにも生っぽいのではないかと思う。1.0にも達していないZigで作られている
  • TypeScriptランタイムを長く望んできたし、TypeScriptも本当に好きだが、ずっと前にJavaを離れたのに、結局Javaにずっと近い何かを探すことになったのは面白い
    結局、自分たちが欲しかったのはJIT、より豊かな型システム、漸進的型付けを備えたJavaだったようだ。npmエコシステムにも欠点は多いが、このエコシステムでライブラリを使うのははるかに負担が少なく、より楽しい
    驚くことに、Rustは言語スペクトルの反対側にあるが、似た感覚を与えることもある
    追記: JITという表現は適切ではなかった。雑にJITと書いたが、JVMとV8で何かを実行するときの起動時間とランタイムの違いを言いたかった。Javaは重く感じるが、JavaScriptエコシステムははるかに機敏に感じる

    • Javaは文字どおりJITという用語を有名にした存在なので、「JITのあるJava」が何を意味するのか分からない
      TypeScriptがJavaにより近いのかもまったく分からない。両者が共有しているのは「Javascript」という誤った名前と命令型プログラミングのサポート程度で、残りは非常に異なると思う
    • 経験上、TypeScriptはJavaよりずっと良い。はるかに冗長でなく、より柔軟
    • 個人的にはJavaよりTypeScriptを使うほうがずっと好き。特にクラスのやや不完全な部分を避け、インターフェースと関数に集中すると使い勝手がずっと良い
      ただしJavaにあってTypeScriptで恋しいのはランタイム型リフレクション。TypeScriptエコシステムにはこれを回避する方法が無数にあるが、どれも少し見苦しいと感じる
    • JavaにはJITがある。そしてTypeScriptの型システムがJavaよりどう機能豊富なのか気になる
    • 自分も似た立場にいる。個人的に欲しいのは、現在のレガシーまたは事実上廃止された部分を除いたC#標準ライブラリとエコシステムに、構造的代数的データ型の手軽さと力を加えたもの
      AoTでもよく、単一バイナリの選択肢があるとよい。理想的にはランタイムがなく、賢い除去ができるとよい。ブラウザ上でJITでも動けばさらに良い
      exhaustiveなパターンマッチングのような、コンパイラと型チェッカーの便利機能も欲しい
  • 自分の好きなDenoの機能がNodeに直接入ってきている。とても良い
    これで型の除去のために常にesbuildをインストールしなくてもよくなるかもしれない。TypeScriptでスクリプトを書いて使うのがずっと簡単になりそうで期待している
    最近は一回限りのスクリプトにはPythonを好んでいたが、型の面では個人的にTypeScriptのほうがPythonより良いと思う。特に数カ月後に見返す大きなスクリプトは、型の恩恵を大きく受ける

  • Nodeにとって本当に出来事の多い1カ月だった。まずv22.5.0でnode:sqliteが追加され、今度はTypeScriptサポートも入ってきている。Nodeの向かう方向が気に入っている

    • Bunの影響や競争のためだと思う。みんなにとって良いこと
    • 最近追加されたテストランナーもとても素晴らしい
  • PRの作者です。何でも聞いてください

    • 次のステップが何なのか、そしてNodeとJSエコシステム全体でTypeScriptがどのような未来を持ちうると見ているのか気になる
  • かなり前からバックエンド作業に Node.js を使い始めたが、PHP でコードを書くより多くの利点がありつつ、Java の問題をあまり持ち込まないように思えた
    ただし Node はやや粗削りで、望む言語にするにはあれこれ付け足さなければならない言語のように感じた。結局 Go を使い始めたが、ずっと扱いやすく、時にはかなり冗長ではあるものの、型安全性のおかげでコーディングが単純になった
    TypeScript は良い選択肢に見えたが、結局また別の継ぎ足しだった。TypeScript を Go の代わりに使うことで、どんな価値が得られるのかよく分からない。明確な型があるのは良いが、Go で解決されている言語上のほかの問題を解決してくれるわけではない。Deno も一部は解決している
    Node が Go に対して持つ大きな利点は、素早くプロトタイプを作れることだが、TypeScript を使うとその利点がかなり小さくなるように思う。だからこれが良い前進なのか、それとも Node を良い選択肢にしていた特性を失わせるものなのか判断しにくい

    • TypeScript はより安全な JS。TS を使っても、依然として JS を書いていることに変わりはない
      「継ぎ足し」という表現を見ると、問題は言語そのものというより、Django のようにすべてを標準で管理してくれる意見の強いフレームワークがないことかもしれない。Django を使うのは好きだが、定められた道から外れるのは少し難しくなる
    • 明らかな答えは言語への慣れだ。プロジェクトのフロントエンドコードが JavaScript や TypeScript なら、Node を使うのは自然な選択だ。共有ライブラリや共有型などを使える
    • すでに JavaScript を知っている、あるいは社内に経験があるなら、Go を学ぶより良い場合があるからだ。私たちも以前のフロントエンド経験があったので cdktf と一緒に使っており、Go より論理的な選択に見えた