💭

7年目のリポジトリで見るany型

に公開

7年目のリポジトリで見るany型

はじめに

TypeScriptにおける厄介で便利な愛すべき型、any型について、生誕7年目を迎えたリポジトリでの例を参考に考えてみる

any型はいつ使うべきか

結論はシンプルだ。使うべきではない。
unknown型を使うべきで、unknown型で代替できない場合は型を定義するべきだ

よく見かけるany型

any型が登場しがちな文脈は大きく分けて4つあった。

  1. ある関数の引数や戻り値について、どのような型がくるか想定が難しいとき
  2. 完全に構造を把握しているわけではないオブジェクトを扱うとき
  3. エラーハンドリング
  4. 時間がないときに書かれたコード

順を追って見ていく。

ある関数の引数や戻り値について、どのような型がくるか想定が難しいとき

例えば、これはHTTPクライアントを提供するライブラリなどで登場する
クエリパラメータとURLを受け取ってGETリクエストを実行し、その結果を返却する以下のような関数について考えてみる

function get(url: string, query_params: any): any {
    let request_url = build_url(url, query_params)
    let response = request.get(request_url)

    return response
}

引数のquery_paramsと戻り値のresponseはリクエストを送るエンドポイントによって異なるし、このgetという関数は特定のエンドポイントに依存する実装にするべきではない。
この場合はジェネリックを使うべきでしょう。

例えば以下のようにです。

function get<T, U>(url: string, query_params: T): U {
    
    let request_url = build_url(url, query_params)
    let response = request.get(request_url)

    return response
}

こうすれば、少なくとも関数の利用者に選択させることができる。
そして、何よりの利点はこの関数を通した前後での型推論の信頼性を損なわないことだ。
ジェネリックを使わないで実装したコードでは、いくら型に厳格なコーディングをしていたとしても、getを利用した後で型を当てなおさなければならず、コンパイラの型推論はget前後で途切れることになる。
そのため、getの利用前後で型安全は損なわれる。
しかし、ジェネリックを使った実装では、getの利用前後で型推論が途切れることがないので、コンパイラに型安全の保障を任せることができる。

完全に構造を把握しているわけではないオブジェクトを扱うとき

たとえば、外部システムのAPIを叩く場合、そのレスポンスの構造を完全に把握することは困難かもしれない。(OpenAPIが公開されていない、OpenAPIが信用できないなど)
その場合、以下のコードの利用者は戻り値の型を完全に把握することは困難になる。

function get<T, U>(url: string, query_params: T): U {
    
    let request_url = build_url(url, query_params)
    let response = request.get(request_url)

    return response
}

/**
 * この時、Uの型が決められない
*/
response = get<T, U>('request_url', query_params)

さらに言えば、常に必要なパラメータだけを持ったオブジェクトを扱える訳ではなく、不要なパラメータをたぶんに含んだJSONをデシリアライズしなければいけないかもしれない。

/**
response: {
  data: {
    property1: {
      ...
    },
    property2: {
      name: 'name',
        age: 0,
          inner_property: {
        ...
      }
    }
  },
  headers: [
    {
      header1: 'header1'
    },
    {
      header2: 'header2'
    },
    {
      header3: 'header3'
    }
  ]
}
*/

/**
 * この時、型Uの定義は煩雑になる
*/ 
response = get<T, U>('request_url', query_params)

こういったときは興味のある部分だけ型を付けるということができる。
TypeScriptの型は良くも悪くも実行時にはその効力を失う。
そのため、定義していないプロパティを含んでいたとしてもデシリアライズできてしまう。

interface Response  {
  data: {
    property2: {
      name: string,
      age: number,
    }
  },
}

/**
 * data.property2.nameおよびdata.property2.ageにしか興味がない時
*/ 
response = get<T, Response>('request_url', query_params)

それでもany型を使いたくなる時、

/**
 * たとえばこんな風に
*/
response = get<T, any>('request_url', query_params)

それは後続の処理で、型では定義されていないresponseのプロパティにアクセスしているからだ。
つまり、定義することが憚られるほど煩雑な構造のオブジェクトのプロパティにアクセスするコードが存在することになる。
それは読みづらく、バグを生みやすい、コードだろうし、逆に言えば静的解析の恩恵を受けやすいコードだろう。

エラーハンドリング

any型はエラーハンドリングでも登場しがちだ。
これにはTypeScriptの言語仕様と心理的な要因の二つの理由があるように思う。

  1. TypeScriptの言語仕様

単純なエラーハンドリングを例示する。
この時catch節内でのeはany型として扱われる。
これはTypeScriptの言語仕様で、throwされるエラーが必ずしもError型を継承した何某かのインスタンスである、という保証がないからだ。

try {
  let response = get('url', 'query_params');
} catch (e) {
  /**
   * この時eの型は暗黙のanyになる
   * これはjaavascriptでthrowされるエラーの型がErrorクラスを継承しているとは限らないため
   */
}
  1. 心理的な要因

これは推測も含む。
エラーハンドリングでは発生したすべてを処理する必要がある。
このため、何をハンドリングしなければいけないかが正しく設計されていなければワイルドカードが欲しくなる。
このとき、catchされるエラーがany型をしていることは渡りに舟だ。
ただし、catchされたエラーをunknown型に書き換えられないならば危険だ。
定義されていないプロパティにアクセスしているコードが存在している。

以下のようにすれば、any型を介することなく全てのエラーをハンドルできる。
もし、一番最後のelse句内でさらに分岐を入れたくなったなら、設計が不十分かもしれない。
なぜなら、else句に入った時点で、エラーは分類に失敗しており、どのような構造になっているのかわからないからだ。

try {
  let response = get('url', 'query_params');
} catch (e: unknown) {
  if (e instanceof InvalidParameterError) {
    // TypeErrorに対する処理
  } else if (e instanceof TimeoutError) {
    // RangeErrorに対する処理
  } else if (e instanceof InternalServerError) {
    // EvalErrorに対する処理
  } else {
    // eの型をunknownとして扱っても行えるワイルドカードの処理
  }
}

時間がないときに書かれたコード

すでに稼働しているプロダクトへTypeScriptを導入する場合、そしてそのプロダクトがJavaScriptで書かれた巨大なコードベースを擁している場合、がんばらないtypescriptという選択肢がある。
これはコンパイルエラーを消しきることができないがためにTypeScriptの導入が難しく、今までJavaScriptを書いていたメンバーの生産性が下がるぐらいなら、any型を当ててしまいTypeScriptの導入は済ませてしまう。という考え方だ。
すべてが型安全になっていることが理想的ではあるが、それが難しいのなら一部だけでも型安全になっていたほうがいいわけだが、これを採用する場合は段階的にany型を消していく、ある程度中長期的な計画が必要だ。
TypeScript導入に続く計画があるのなら取ってはいけない選択肢ではないが、その後の計画がないのなら、any型は割れ窓以外の何物でもない。
そして、今まで型を扱っていなかったメンバーの生産性がネックになり、チームがその分のコストを払えないのなら、TypeScirpt導入は延期するべきだ。
あなたがチームの技術選定に対して何らかの形で関わることができるなら、TypeScript導入により発生する追加のランニングコストを払っても、その安全さの恩恵が受けられるチームを作るほうがずっと得られるものは多いだろう。

そして、any型はどうなったのか

表題にもなっている7年目のリポジトリの話をしよう。
これは、プロダクトとしては誕生して10年以上(その間、2度のメジャーバージョンアップをしている)経過しているが、現行のリポジトリではその最新版であるバージョン3のみを扱っている。
このプロダクトはフロントエンドがVue + TypeScriptで書かれていて、eslintとtscがCIに組み込まれている
残念なことに、バックエンドAPIからの戻り値がany型として扱われ、それがModelでもModelViewでの吸収されず、データ構造がViewにまで影響を与えてしまっているというのが現状だ
MVVMという階層構造を越境できるほどany型の生存力は強い
通常の開発時にもView層まで伝播したany型は厄介なものだが、大規模な書き換え、特にmodel層で受けていたデータ構造の変更を伴う修正を行う際には、越境したany型が猛威を振るう。
実際、当プロダクトが利用している外部APIの大規模な作り替えがあり、それに伴って、View層までany型に浸食されたこのプロダクトも書き換えが必要になった。
この際、完全な型安全を手に入れていれば、影響範囲の洗い出しはtscならびにeslintのエラーだけで発見できたはずだが、any型が出現した以降はこの方法が理屈上使えず、grepという肉体労働に従事することとなった

対応方法

基本的にはCIでtscとeslintを走らせるべきでしょう。
この際、まずは以下2つの設定を入れるだけで十分でしょう。
もちろん、特にeslintに関してはもっと細かな設定をすることで、もっと安全な開発環境を手に入れることができます。

.eslintrc
@typescript-eslint/no-explicit-any: error
tsconfig.json
"strict": true

Discussion