😈

@ts-ignoreで型エラーをねじ伏せるのはアカンのか

に公開

はじめに

発端の話

TypeScriptド初心者の私がコーディングを進めていると、こういう状況に出会いました。

  • コードの動きとしては合っているつもり
  • でもTypeScriptに怒られてるんよな~
  • エラーを消す方法を調べた結果、とりあえず// @ts-ignoreを付けてビルドだけ通すやで~

でもちょっと待てよ、これって本当に大丈夫か…?

この関係各所に対する理解がふわっとした状態のまま次に進むのは危険だと思いました。
なので今回、

  • なぜTypeScriptに怒られてたんや??
  • @ts-ignoreってそもそもなんぞや??
  • とりあえず@ts-ignoreで潰すのってどこまでアリなんや??

というのを自分なりに整理してみようと思いました。

サンプルケース

まずは実際に遭遇したケースを、シンプルなコードに落とし込んだものが以下のようなケースです。

ts
// スクロール位置を変える関数(ライブラリ関数っぽいもの)
function moveScrollTo(position: number) {
  console.log(`スクロール位置を ${position}px に移動しました`);
}

// ラッパー関数:引数が渡されたときだけスクロールしたい
function setScroll(position?: number) {
  if (arguments.length) {
    // NG:ここで怒られる
    moveScrollTo(position);
  }
}

やりたいことはシンプルです。

  • setScroll(100)のように引数が渡されたときだけmoveScrollToを呼びたい
  • 引数なしでsetScroll()が呼ばれたときは何もしない

JavaScript的にはこれで動くかと思いますが、TypeScriptは以下のように怒ってきます。

Argument of type 'number | undefined' is not assignable to parameter of type 'number'.
Type 'undefined' is not assignable to type 'number'.ts(2345)

とりあえず@ts-ignoreでねじ伏せるやで

最初にやってみたのは以下のような実装です。

ts
function setScroll(position?: number) {
  if (arguments.length) {
    // とりあえず@ts-ignoreで黙らせる
    // @ts-ignore
    moveScrollTo(position);
  }
}

これでどうなったかというと、

  • ビルドは通る
  • エディタの赤線も消える
  • 実行してみてもとりあえず動く

問題なく動いたのでヨシ!と一見解決したように見えます。

…が、ふと冷静になると不安になってきました。

  • そもそもなぜ怒られていたのか分からないままになっている
  • ここは本当に無視していいエラーなのか??というのが自分でも説明できない
  • 数ヶ月後の自分がこのコードを見たら確実に「なんでignoreしてるんだっけ?」ってなる

というわけで、ここで一旦立ち止まって そもそも@ts-ignoreって何者なんや?? というところからちゃんと見ていくことにしました。

@ts-ignoreってそもそもなんぞや??

@ts-ignoreは、コメントの形で書けるTypeScript独自の指示です。

ts
// @ts-ignore
moveScrollTo(position);

意味としてはすごくシンプルで、

次の1行で発生するTypeScriptのエラーを見なかったことにして~✨

という魔法の呪文です。

  • コンパイル時、その行に対する型エラーは報告されなくなる
  • エディタ上の赤線も消える
  • でも、出力されるJavaScriptは一切変わらない

つまり、

  • 型のチェックだけを無効化している
  • ランタイムの挙動やバグはそのまま

というものになります。

今回のケースに当てはめると、

  • TypeScriptが「number | undefinednumberに渡そうとしてるぞ!」と怒っている
  • その怒りを@ts-ignoreで「見なかったこと」にしているだけ

とも言えます。

便利と言えば便利なんですが、
やり方としてはかなりパワープレイというか、見て見ぬふり感があります。

今回のエラーの正体

ちょっとだけ上でネタバレしましたが、
TypeScriptは具体的に何に怒っていたんや??というのを見てみます。

もう一度、エラーが出ていた部分を登場させましょう。

ts
function setScroll(position?: number) {
  if (arguments.length) {
    // NG:ここで怒られる
    moveScrollTo(position);
  }
}

ポイントは、この引数の型です。

ts
function setScroll(position?: number) { ...

position?: numberという書き方は、以下の意味の糖衣構文です。

position: number | undefined

つまりTypeScriptからすると、positionの型は常にnumber | undefinedです。

一方で、moveScrollToは以下のように定義していました。

ts
function moveScrollTo(position: number) {
  console.log(`スクロール位置を ${position}px に移動しました`);
}

こちらは引数positionに純粋なnumberだけ欲しがっています。
undefinedは受け入れられません。

そこに対してnumber | undefinedかもしれない値をそのまま渡しているので…

ts
// ↓このpositionは「number」だけ欲しいのに
moveScrollTo(position);
// ↑「number | undefined」かもしれない値を渡す

となるとTypeScriptが

お前これundefinedが渡ってくる可能性あるやん!!😡

と怒るのは、実はめちゃくちゃ真っ当なんですよね。

条件式で書いたarguments.lengthは型を狭めてくれない

ではなぜこのifではダメだったのか。

ts
function setScroll(position?: number) {
  if (arguments.length) {
    moveScrollTo(position);
  }
}

人間の目線だと以下のように考えているので、
そもそも moveScrollToundefinedが渡ることはないじゃんとなります。

  • arguments.length === 0 → 引数なし → positionundefined
  • arguments.length === 1 → 引数あり → positionnumberのはず…

でもTypeScriptの型システムは、arguments.lengthを見てもpositionの型を変えてくれません。

TypeScriptが「この中では型を狭めてもいいな」と判断できるのは、例えばこういうパターンです。

ts
if (position !== undefined) {
  // この中ではpositionの型はnumber
}

if (typeof position === "number") {
  // この中でもpositionの型はnumber
}

このようなpositionそのものをチェックしている条件式は
型を狭めるためのヒントとして扱ってくれます。

一方、arguments.length

  • 関数に渡された引数の数を見ているだけ
  • positionという変数自体をチェックしているわけではない

ので、TypeScript的には

う〜ん、positionは相変わらずnumber | undefinedということにしとくやで~😎

となってしまう、というわけです。
ここが私の思考の落とし穴でした。

コードを書き換えて@ts-ignoreなしで通す

やりたかったことを言い換えると、以下のような感じでした。

positionが渡されている(=undefinedではない)時だけスクロールしたい

であれば、その前提をTypeScriptにも分かる形で書いてあげればよさそうです。

ts
// スクロール位置を変える関数
function moveScrollTo(position: number) {
  console.log(`スクロール位置を ${position}px に移動しました`);
}

// ラッパー関数:引数が渡されたときだけスクロールしたい
function setScroll(position?: number) {
  if (position !== undefined) {
    moveScrollTo(position);
  }
}

変更点は 1 行だけです。

ts
-  if (arguments.length) {
-    moveScrollTo(position);
-  }
+  if (position !== undefined) {
+    moveScrollTo(position);
+  }

こう書き換えることで、

  • if (position !== undefined)のブロック内では
    positionの型がnumber | undefinednumberに狭められる
  • moveScrollTonumberを受け取る関数なので、エラーは発生しなくなる

という状態になりました。

やりたかったロジック(引数がある時だけスクロールする)はそのままに、
@ts-ignoreに頼らなくていい形に持っていけたわけです。

この時点で、

今回みたいなケースで@ts-ignoreを使うのは、やっぱり違うよな…

という感触が私の中でもだいぶハッキリしてきました。

結局@ts-ignoreってどういう時に使うものなんや??

ここまで来ると、次に気になるのはこれです。

自分なりの結論としては、

  • 基本は使わない方針
  • それでも現実問題やむを得ない場面はある

くらいのスタンスがちょうど良さそうだと思いました。

仕方なくアリかなと思うケース

例えば、こんな状況があるかなと思いました。

  • 外部ライブラリの型定義が明らかに間違っているor古い
  • ライブラリ自体は正しく動くのに型だけがズレている
  • JavaScriptで書かれた巨大レガシーコードを、
    段階的にTypeScript化している途中
  • 一部だけどうしても型が付けられないorパッと見ではわからない

こういう時に限って一時的に@ts-ignoreを使うのはアリだと思いました。
ただしその場合でも、

  • なぜ無視していいのかコメントに理由を書く
  • できればissue番号やTODOを一緒に書いておく

くらいは書いておくべきだと思いました。

例:

ts
// @ts-ignore: ライブラリ側の型定義が誤っているため。一時的に無視(issue#1234)
thirdPartyLib.callApi(value);

今回のようなケースでは避けたい

逆に今回のようなケースでは、

  • ちょっとコードの書き方を変えるだけで
  • TypeScriptの言っていることにも筋が通って
  • かつ@ts-ignoreなしでコンパイルが通る

という状況でした。

こういうときに@ts-ignoreを使ってしまうと…

  • 型チェックという安全機構を自ら破壊している
  • 将来のリファクタ時にバグが紛れ込みやすくなる

なのでできるだけ避けた方がよさそう、というのが今回の学びです。

まとめ

最後に今後の自分への戒めも込めて、
@ts-ignoreを書く前に確認したいポイントをメモしておきます。

  • まずコード側を直せないかちゃんと考えたか??
    • 型ガード(value !== undefinedなど)で表現できないか?
  • それは本当にライブラリ側の型定義が間違っているだけでは??
  • 一時しのぎで@ts-ignoreを書くにしても、
    なぜ無視していいのかコメントで説明しているか?
  • 将来見直すためのissue番号やTODOを残したか?

@ts-ignoreは、TypeScriptのチェックを一時的に外すための強力な呪文です。

だからこそ、

よく分からないからとりあえず唱えて解決させる魔法の呪文ではなく
どうしても必要な時だけ解禁する禁術

くらいの扱いにしておくのがちょうどいいのかな、と思いました。

Discussion