🎃

もうTypeScriptの補助輪を外そう 明日は//@ts-checkを使う

5 min read 9

※近所(同じ港区内)のあのIT企業さんからお声をかけていただきました。登壇予定です。
※某商業誌さんから執筆依頼がありましたが、有料ですと気が引けるのでお断りしました。


Vimで書いている人はもういないから、そろそろTypeScript(自転車の補助輪)を外していこうという話。

Visual Studio Code は下のリンクからインストールできる。これが前提。

https://azure.microsoft.com/ja-jp/products/visual-studio-code/
VS Code で // @ts-check が利用できる。
 
※Denoの話を知らない人は、これを読むと良い。
DenoがTypeScriptの使用をやめる5つの理由
https://startfunction.com/deno-will-stop-using-typescript/
(Denoは、Node.jsの作者であるライアン・ダールによって作成され、V8 JavaScriptエンジン及びRustプログラミング言語に基づいた、JavaScript及びTypeScriptのランタイム環境である。セキュリティと生産性に焦点を当てている)
 
※TypeScriptのESMについては、これを読むと良い。
TypeScript 4.5 以降で ESM 対応はどうなるのか?
https://zenn.dev/teppeis/articles/2021-10-typescript-45-esm
(とてもタイムリーな話題だ)
 
これらは氷山の一角に過ぎない。
あなたのリーダーにこの話をしたとき、言葉を濁されたり、笑い飛ばされるようだったら、あなたのプロジェクトは破滅への道を進んでいるのかもしれない。
SNSでの注意点だが、情報商材屋が組織を作っているので、気をつけてほしい(エンジニア採用で問題となっている)。
 
銀の弾丸と呼ばれた補助輪に暗雲が立ち込めている。今後、脱TypeScriptの加速は避けられないが、段階を踏んで安全に進むべきである。

TypeScriptを活用したJSプロジェクト

明日からはJavaScriptファイルにおける //@ts-check を使用してほしい。

JSDocを使ってJSで型ヒントを提供する

.jsファイルでは、多くの場合型を推測することが可能。型が推測できない場合、JSDoc構文を使って指定することができる。

宣言の前でJSDocのアノテーションを使い、その宣言の型を設定する。

例えば

/** @type {number} */
var x;
 
x = 0; // OK
x = false; // OK?!

前述のコードサンプルの最後の行はTypeScriptではエラーとなりるが、JSプロジェクトのデフォルトではエラーを発生しない。
JavaScriptファイルでエラーを有効化するには、.jsファイルの最初の行に //@ts-check を追加して、TypeScriptにエラーを発生させるようにする。

// @ts-check
/** @type {number} */
var x;
 
x = 0; // OK
x = false; // エラー
Type 'boolean' is not assignable to type 'number'.

エラーを追加したいJavaScriptファイルがたくさんある場合は、jsconfig.jsonを使用するように変更する。 ファイルに //@ts-nocheck コメントをつけることで、ファイルのチェックを省略することができる。

納得できないようなエラーは前の行に //@ts-ignore または //@ts-expect-error を追加すれば良い。

// @ts-check
/** @type {number} */
var x;
 
x = 0; // OK
// @ts-expect-error
x = false; // エラー

JSDocリファレンス

以下のリストは、JavaScriptファイルの型情報を提供する JSDocアノテーションにおいて、現在サポートされている構文の概要。以下に明示的にリストに入っていないタグ(@asyncなど)はまだサポートされていないことに注意。

  • @type
  • @param (or @arg or @argument)
  • @returns (or @return)
  • @typedef
  • @callback
  • @template
  • @class (or @constructor)
  • @this
  • @extends (or @augments)
  • @enum

class拡張
プロパティ修飾子 @public、@private、@protected、@readonly
タグの意味は通常、jsdoc.appで与えられたものと同じか、あるいはそのスーパーセットになる。

@type

“@type”タグを使用すれば、型名(プリミティブ、TypeScript宣言やJSDocの”@typedef”タグで定義されたもの)を参照することができる。ほとんどのJSDoc型と、stringのような最も基本的なものからConditional Typesのような高度なものまで、あらゆるTypeScriptの型を使うことができる。

/**
 * @type {string}
 */
var s;
 
/** @type {Window} */
var win;
 
/** @type {PromiseLike<string>} */
var promisedString;
 
// DOMプロパティを使ってHTML要素を指定することができる。
/** @type {HTMLElement} */
var myElement = document.querySelector(selector);
element.dataset.myData = "";

@param @returns

@paramは@typeと同じ型の構文を使用するが、パラメータ名を追加する。また、パラメータ名を角括弧で囲むことで、パラメータを任意のものとして宣言することもできる。

// パラメータは様々な構文形式で宣言することができる
/**
 * @param {string}  p1 - 文字列パラメータ
 * @param {string=} p2 - 任意のパラメータ(Closure構文)
 * @param {string} [p3] - 任意のパラメータ(JSDoc構文).
 * @param {string} [p4="test"] - デフォルト値を持つ任意のパラメータ
 * @return {string} 結果
 */
function stringsStringStrings(p1, p2, p3, p4) {
  // TODO
}

@typedef @callback @param

複雑な型を定義するために@typedefを使うことができる。@paramを使った同様の構文でも動作する。

/**
 * @typedef {Object} SpecialType - 'SpecialType'という名前の新しい型を作成
 * @property {string} prop1 - SpecialTypeの文字列プロパティ
 * @property {number} prop2 - SpecialTypeの数値プロパティ
 * @property {number=} prop3 - SpecialTypeの任意の数値プロパティ
 * @prop {number} [prop4] - SpecialTypeの任意の数値プロパティ
 * @prop {number} [prop5=42] - SpecialTypeのデフォルト値を持つ任意の数値プロパティ
 */
 
/** @type {SpecialType} */
var specialTypeObject;
specialTypeObject.prop3;

@template

ジェネリクス関数は@templateタグを使って宣言することができる。

/**
 * @template T
 * @param {T} x - 戻り値に流用するジェネリクスパラメータ
 * @return {T}
 */
function id(x) {
  return x;
}
 
const a = id("string");
const b = id(123);
const c = id({});

@constructor

コンパイラはthisプロパティの代入に基づいてコンストラクタ関数を推測するが、@constructorタグを追加すればより厳密なチェックとより良い提案を受けることができる。

/**
 * @constructor
 * @param {number} data
 */
function C(data) {
  // プロパティの型は推測される
  this.name = "foo";
 
  // あるいは、明示的に設定することもできる
  /** @type {string | null} */
  this.title = null;
 
  // また、他のところで設定されている場合は、単に型注釈をつけることもできる
  /** @type {number} */
  this.size;
 
  this.initialize(data);
Argument of type 'number' is not assignable to parameter of type 'string'.
}
/**
 * @param {string} s
 */
C.prototype.initialize = function (s) {
  this.size = s.length;
};
 
var c = new C(0);
c.size;
 
var result = C(1);
Value of type 'typeof C' is not callable. Did you mean to include 'new'?

@this

コンパイラは通常、thisが用いられるコンテクストからthisの型を推測することができる。推測できない場合、@thisを使って明示的にthisの型を指定することができる。

/**
 * @this {HTMLElement}
 * @param {*} e
 */
function callbackForLater(e) {
  this.clientHeight = parseInt(e);
}

@extends

JavaScriptクラスがジェネリクスの基底クラスを拡張するとき、型パラメータが何であるべきかを指定するところはない。@extendsタグはそのような型パラメータを指定する方法を提供している。

/**
 * @template T
 * @extends {Set<T>}
 */
class SortableSet extends Set {
  // ...
}

@enum

@enumタグを使うと、すべてのメンバが指定された型であるオブジェクトリテラルを作成することができる。JavaScriptのたいていのオブジェクトリテラルとは異なり、明示されていないメンバは使用できない。

/** @enum {number} */
const JSDocState = {
  BeginningOfLine: 0,
  SawAsterisk: 1,
  SavingComments: 2,
};
 
JSDocState.SawAsterisk;

サポートされていないタグ

TypeScriptはサポートされていないJSDocタグを無視する。以下のタグは、サポート目標。

@const
@inheritdoc
@memberof
@yields
{@link …}

さあ、補助輪を外そう。

※せっかくコメントをいただいたのに、Zennの運営が勝手に削除したようです。とても残念です。

Discussion

あなたの意見に同意します。私が尊敬するWeb App Developerも同じ考えです。
天才であれば、この補助輪(TypeScript)は邪魔でしょう。外すことを薦めます。
しかし、天才でなければ、この補助輪を使用しない理由はないでしょう。
関連記事を書きました。

https://zenn.dev/hooyan/articles/9472dd285434c6

Deno が内部コードで TypeScript を辞めた件について誤解や憶測が広がっていると思いますので、ここにその決定をしたドキュメントの一部を DeepL で翻訳したものを引用します。

2020年6月10日更新。
このデザインドキュメントがより広く議論されているのを見ました。ほとんどの人は、この狭い技術文書を理解するための文脈を持っていません。この文書は、Deno の内部における非常に特定の、非常に技術的な状況にのみ適用されます。これは、一般的な TypeScript の有用性についての考察では全くありません。また、Deno の一般に公開されているインターフェイスについての議論でもありません。もちろん、Deno は永遠に TypeScript をサポートします。TypeScript で書かれたウェブサイトやサーバーは、Deno とはまったく異なるタイプのプログラムです。おそらく、初心者プログラマーが理解するよりもはるかに異なるでしょう。対象としているのは、この特定の内部システムに携わる5人から10人の人々です。くれぐれも大げさな結論を出さないでください。

https://docs.google.com/document/d/1_WvwHl7BXUPmoiSeD8G83JmS8ypsTPqed4Btkqkn_-4/preview

あなたのリーダーにこの話をしたとき、言葉を濁したり、笑い飛ばすようだったら、あなたのプロジェクトは破滅への道を進んでいるのかもしれない。

私の指摘の後にこの一文が追記されているのを確認したので反論させてください。なお人格攻撃をするつもりはなく、間違えを指摘するつもりです。初心者による技術記事執筆のすすめを参考にしています。


※Denoの話を知らない人は、これを読むと良い。
DenoがTypeScriptの使用をやめる5つの理由

https://startfunction.com/deno-will-stop-using-typescript/

これは氷山の一角に過ぎない。
あなたのリーダーにこの話をしたとき、言葉を濁したり、笑い飛ばすようだったら、あなたのプロジェクトは破滅への道を進んでいるのかもしれない。
銀の弾丸と呼ばれた補助輪に暗雲が立ち込めている。今後、脱TypeScriptの加速は避けられないが、段階を踏んで安全に進むべきである。

私の観測範囲では脱 TypeScript はそもそも起きていません。少なくとも Deno はかなり特殊な例であって脱 TypeScript の一例としてあげるのは間違っています。Deno の内部コードは通常の Web アプリケーションとは違い、以下のような特徴があります。

  • JavaScript エンジンにテキストを読み込ませてコードを実行するのではなく、V8 のスナップショットを作りそれを起動している[1]
    • 内部コードに TypeScript を使う場合、スナップショットを作るための TypeScript コンパイラとユーザーのコードを実行するための TypeScript コンパイラの二重管理が必要になってしまう
  • Fetch API や WHATWG Streams API など Web Standards の仕様に準拠した API を多く提供している
    • 厳密に仕様に準拠した実装のためには通常のクラス構文では達成できないことがあり、Object.createObject.setPrototypeOf などを使わざるをえないケースがある[2]。この場合 TypeScript のベストプラクティスに当てはまらない
    • 動的型変換を多用するため引数の型を制限できず、unknown だらけになってしまう
    • 実装では動的な型変換を多用することになる一方で、型定義ファイルでは型を制限したいという乖離が発生する[3]。この場合は TypeScript 一つのファイルから JavaScript と型定義ファイルの両方を出力するのは分が悪いため、最初から別々に記述することになり TypeScript のメリットが薄れてしまう

TypeScript は辞めるべきであるという結論ありきの議論になっていると思います。今一度確認していただけると幸いです。

脚注
  1. Deno のコントリビューターである kt3k さんの記事 Deno が Node.js に依存しなくなった を参考 ↩︎

  2. 例えば ReadableStream の実装 では %AsyncIteratorPrototype% を扱うために Object.setPrototypeOf を使っている ↩︎

  3. 例えば Math.abs は ECMAScript の仕様としては引数に string を入力した場合に number に動的型変換するが、型定義においては暗黙的型変換を防ぐため number しか受け取らないようになっている。言語仕様に近いところではこのように実装と型定義の乖離が起きる ↩︎

余談ですが私は Stage 1 Float16Array を実装したライブラリを公開しています。

https://github.com/petamoriken/float16

ここでは JSDoc による型アノテーションを使った JavaScript ファイルと型定義ファイルを別々で管理しており、TypeScript は使っていません。これは Deno の例と同じように ECMAScript の仕様に厳密に準拠する場合は TypeScript は適さないと判断したからです。

TypeScript を採用するべきかどうかはプロジェクトに依存します。しかし(どのようなプロジェクトに対して脱 TypeScript を提唱しているのか私にはわかりませんが)通常のアプリケーションを開発するにあたっては TypeScript をあえて使わない理由はないと思っています。

何かと叩かれているのを見たのですが、JSDoc だけでも TS の恩恵はほとんど得られると思いますし、この記事が TS 不要論であるようには見えません。そういう体制の OSS に最近 contribute したこともありますが普通に快適でした。(.js + .d.ts) からこの記事のように (.js(JSDoc) → .d.ts 生成) へシフトするのは良いと思います。

両者の無視できない非常に大きな違いの一つは as が明示的か暗黙的かというところでしょう。基本的に高級な領域で書く場合は as がほとんど現れず、as というのが「注意せよ(=書いた本人が妥当性を証明・保証せよ)」という目印になるから自分は TypeScript (.ts 系) を使います。

Deno の件は、コア部分であるがゆえに、TS の作り上げた文脈に乗り切らず as が多くなりすぎるのが容易に想像つきます。そういうのを JSDoc に切り替える基準として考えようとするのはいいことだと思います。しかしたいてい、アプリケーションに該当するものはそうはならず、もし繰り返し as が現れたなら、それはその箇所を関数に収めてその関数をテストすることに注力することで解決すると思います。

(しかしこの記事が出るまで私はこれほど簡潔に TS vs JSDoc を説明できたとは思えないので、その点についてありがたく思います。)

(完全に余談ですが私は何年も、そして現在も TS を完全に vim のみで書いています)

良記事をありがとうございました!

JSDoc をきちんと書くだけでビルドの待ち時間がゼロになるのは快適やと思いまする。

TypeScript はビルドが遅いという誤解が広がっていると思われるのでコメントを残します。

決してビルドが遅いわけではなく、tsc を使った型チェックに時間がかかるだけです。これは TypeScript を採用した場合でも JSDoc を使った JavaScript を採用した場合でも同様です。

単に TypeScript から JavaScript に変換するだけであれば基本的に型アノテーション部分を取り除くだけで済むため低コストです。TypeScript の公式ドキュメントに記載してあるように Babel を使って変換することができます。特に最近は swc そしてバンドルするなら esbuild といった選択肢もあります。

JSDoc ならそのまま実行できるためビルド不要というメリットがあるのはわかりますが、そもそもビルドを一切することなくアプリケーションを開発するケースというのはかなり限られていると思います。Node.js でのみ使われるアプリケーションか、バンドラーが必要ない小規模な Web アプリケーションか、またはモジュールを使用していないクラシックな環境か。

どういうプロジェクトについて述べているのか前提がないためわかりませんが、今一度再考していただけると幸いです。

※TypeScriptのESMについては、これを読むと良い。
TypeScript 4.5 以降で ESM 対応はどうなるのか?

https://zenn.dev/teppeis/articles/2021-10-typescript-45-esm

今度は TypeScript の Node.js ES Modules 対応についての記事が追記されていますね。その記事の中身を吟味することなく不用意に TypeScript へのネガティブキャンペーンをしてしまっていると思われます。その中身を一部引用します。

今後どうなるかは分かりませんが、この記事では現時点で提案されている TypeScript の ESM 対応について解説し、従来の ESM 対応の問題点をどう解決するのか、今後の課題は何か、などについて現状確認していきます。内容としては動向を追っかけたいライブラリやフレームワークの作者、物好きな人や向けなので、一般の TS ユーザーは状況が固まってベストプラクティスや便利ツールが出てきたあとでキャッチアップするのが良いかと思います。

Node.js は従来の CommonJS スタイルの互換性を保つ決定をしたため ES Modules 対応がかなり複雑化してしまいました。Node.js v12 が Maintenance LTS となったことでサポートするすべてのバージョンで ES Modules が使えるようになり、TypeScript でもその対応に向けての実装や議論が始まったという認識でいます。

まだ対応が不十分だというのはわかりますが一朝一夕で解決される性質のものではありません。そもそも Node.js の ES Modules 対応は急を要するものではなく、npm へライブラリを公開している人たちが互換性を保ちながら少しずつ移行するものとなっています。記事にも述べられているように一般のアプリケーション開発者が気にするべき話題ではありません。

ログインするとコメントできます