📘

TypeScriptの型と実態がズレるとき、型付けにどう向き合っていくか

mybest2022/09/08に公開4件のコメント

はじめに

こんにちは!株式会社マイベストでフロントエンドエンジニアを担当している おぎー と言います!

この記事ではTypeScriptの型と実行時の値が噛み合わなくなる話と、型付けへの向き合い方についてお話できたらと思います。

TypeScriptの型付けについて

型があるメリットとは

TypeScriptは静的型付け言語です。プログラムを実行せずともコンパイルの段階で型の噛み合わないコードを発見することができます。このおかげで実行するコードは一定品質を担保されている状態を保ちやすい作りになっていると言えます。

またVS Codeをはじめとした開発用エディタではリアルタイムに型チェックしてくれるため、間違った記述にいち早く気づき修正することができます。

これらを踏まえると、素早く一定の品質を保たれたコードを作れることがポイントになるかなと思います。

型と実態がズレるとは

TypeScriptは静的型付け言語なのでコンパイルを行う段階で型が定まっています。コンパイルでTypeScript固有のコードは削除され、JavaScriptに変換されるので実行時は動的型付け言語になります。

そのためTypeScriptを書く段階で適切な型付けができていないと、TypeScriptの型と実態がズレてしまいます。これを私は「型と実態がズレた状態」と呼んでいます。(以降、ズレたと呼びます)

type User = {
  name: string;
}
const user = {} as User;

// TypeScriptの世界ではnameプロパティが存在していると見なされ問題ないが、
// JavaScriptとして動作させるとnameプロパティが存在しないのでエラーになる
// Cannot read properties of undefined (reading 'toUpperCase') 
user.name.toUpperCase();

ズレた状態になってしまうと実行時にランタイムエラーや誤った演算を行う可能性がでてきます。こうなってしまうとTypeScriptによるメリットが受けにくくなるどころか、思わぬ不具合に振り回される足枷となってしまいます。

どんな時に型と実態がズレてしまうのか

では、どんな時に型と実態がズレてしまうのでしょうか?

ここではいくつかズレてしまうパターンを紹介しつつ、対応方法について考えていきたいと思います。

パターン1: Type Assertionを使ってしまう

Type Assertion(as構文)を使うと、コンパイラーに別の型を伝えて型を上書きすることができます。しかし、型を上書きしているだけで実際の値は何も変わりません。そのため、実態にそぐわないズレた状態になる可能性があります。

以下はズレる例になります。

type User = {
  name: string;
}
const user = {} as User;

// Cannot read properties of undefined (reading 'toUpperCase') 
user.name.toUpperCase();

対応方法

Type Assertionを使う前に、まず型ガードやユーザー定義型ガードを使うことで解決できないかを検討しましょう。

やむを得ず使う場合は狭いスコープで使い、上書きされた型の適応範囲が最小限になるよう意識することが大事です。

パターン2: Non-null assertion operatorを使ってしまう

Non-null assertion operator(!構文)を使うと、nullとundefinedの型の可能性が排除された型に上書きすることができます。こちらもType Assertionと同様に型を上書きしているだけで実際の値は何も変わらないためズレた状態になる可能性を秘めています。

以下はズレる例になります。

type User = {
  name?: string;
}
const user: User = {};

// Cannot read properties of undefined (reading 'toUpperCase')
user.name!.toUpperCase();

対応方法

こちらも同様に、まず型ガードやユーザー定義型ガードを使うことで解決できないかを検討しましょう。やむを得ず使う場合も同様で、狭いスコープで使い影響が最小限に留まるよう意識することが大事です。

パターン3: any型を使ってしまう

TypeScriptのany型はどんな型としても扱える型です。

これを言い換えると「stringでもあって、numberでもあって、どんなプロパティでも持っているobjectでもあって、何もかもが入る配列でもあって...何でもありな型」と言うこともできます。常にズレている型と言っても良いと思います。

また、any型から違う値を作り出すとany型が伝染する問題を秘めています。使い方次第ではズレが広がってしまいます。

以下はany型の伝染とランタイムエラーを表した例になります。

const hoge: any = 'hoge';

// 意図せぬ関数呼び出しができてしまう
const double = (num: number): number => num * 2;
double(hoge);

// any型から得た別の値にany型が伝染する
const fuga = hoge.fuga;   // -> fugaはany型
const fuga = hoge.piyo(); // -> piyoはany型

// 実態にないプロパティを指すとランタイムエラー
console.log(hoge.a.b);

対応方法

できるだけany型以外の書き方ができないか試してみましょう。

適切な型がわからないとしても分かるところまで具体的に定義することが大事です。例えば、配列であることが分かっているなら any[] 、objectだとわかっているなら { [key: string]: any } といった具合に少しだけ具体的にすることが可能です。少しだけ型がついているだけでも一部コードが型安全になったり、コードからぼんやり実態が想像できる状態にすることができます。

また、こちらも使う場合は極力狭いスコープで使うことが重要です。こうすることでズレの影響範囲を減らせるだけでなく、any型が不要に伝染することをある程度防ぐことができます。

パターン4: 存在しないindexを指してしまう

配列に対してindexを指すと値が存在する前提で型推論が行われます(タプル型は除く)。そのため存在しないindexを指してしまうとズレた状態になってしまいます。

以下はズレる例になります。

const array: string[] = ['donuts', 'cake', 'waffle'];
// itemはstringとして推論される、実態はundefined
const item = array[100];
// Cannot read properties of undefined (reading 'toUpperCase')
item.toUpperCase();

対応方法

Index Access以外で扱う方法検討することができます。例えば、全ての値を見る必要があるのであれば、for-ofArray.prototype.map() といったものを使えば、存在しないindexを指すことがなくなり型安全に扱うことができます。

また、コンパイラオプションのnoUncheckedIndexedAccessを有効にすると型安全にすることができます。有効にするとIndex Accessの際にundefinedとのユニオン型として推論されるようになります。

パターン5: 存在しないプロパティを指してしまう

オブジェクトでもインデックス型(index signature)を使うことで存在しないプロパティが存在する前提で型推論が行われることがあります。

以下はズレる例になります。

const obj: { [key: string]: string } = { a: 'a' };
// itemはstringとして推論される、実態はundefined
const item = obj.b;
// Cannot read properties of undefined (reading 'toUpperCase')
item.toUpperCase();

対応方法

対処法として、インデックスシグネチャを使わない形にすることが考えられます。プロパティのフィールドが定まっているのであれば、各プロパティの型を定義することで防ぐことができます。もしプロパティが動的に変化する場合はMapオブジェクトを使う形に直すとundefinedとのユニオン型になるので型安全にできます。

const hoge = new Map<string, number>();
hoge.set('hoge', 1);
hoge.get('hoge'); // -> number | undefinedになる

また、配列の時と同様にコンパイラオプションの noUncheckedIndexedAccess を使うことでもundefinedとのユニオン型にすることができます。

パターン6: Type Guardに割り込んでしまう

Type Guardを使うと型を絞った状態で後続のコードで記述できますが、処理の書き方によっては絞られた型と違う値がくることがあります。

以下のズレた値が割り込んでくる例です。

class Hoge {
  value: string | null = "str";
  resetValue() {
    this.value = null;
  }
  func() {
    if (this.value !== null) {
      /** この時点で this.value はstring型として推論、実態もstring型 */
      this.resetValue();
      /** この時点で this.value はstring型として推論、実態はnullでズレた状態 */
      // Cannot read properties of undefined (reading 'toUpperCase')
      console.log(this.value.toUpperCase());
    }
  }
}

対応方法

現状だと仕組み的に防ぐ方法はありません。型を絞った後に型が変わるようなコードを書かないことが重要になります。

パターン7: 破壊的なメソッドを使ってしまう

値を直接書き換える破壊的なメソッドを使うと、型が追従せずズレてしまうことがあります。

以下はズレる例です。

const array: ['a', 1] = ['a', 1];
// 破壊的に最初の要素を取り除く
array.shift();
// strは'a'型として認識されるが、実態は1
const str = array[0];
// Cannot read properties of undefined (reading 'toUpperCase')
str.toUpperCase();

対応方法

破壊的なメソッドを使う際は、型と処理を考えて上で使うことが大事です。意図的に破壊的なメソッドから守りたい変数があれば readonly 修飾子をつけることでも防ぐことが可能です。もしくはそもそも破壊的なメソッドを使わず、別の形に書き直せないか考えるのも1つの手です。

また、関数の引数やReactのpropsといったものに破壊的なメソッドは絶対使ってはいけません。スコープ外でも知らぬ間にズレる状態になってしまいます。

パターン8: @ts-expect-error や @ts-ignore を使ってしまう

@ts-expect-error@ts-ignore を使うと、次の行で発生するエラーを全て無視する特殊なコメントです。無視するエラーの中には当然型エラーも含まれています。

これらの方法で型エラーを無視すると誤った推論のまま認識され続けてしまう上に、Type AssertionやNon-null assertion operatorとは違い、本来上書き不可能な型すらも許すたちの悪いズレ方になります。(Type Assertionも as any as といった書き方がありますが...)

以下は @ts-expect-error を使ってズレてしまった例です

// strはstring型として認識されてしまう
// @ts-expect-error
const str: string = 1;
// Cannot read properties of undefined (reading 'toUpperCase')
str.toUpperCase(); 

対応方法

@ts-expect-error@ts-ignore は原則使わない事を推奨します。

例外があるとすれば、TypeScript導入時など大きな技術移行の時の一時しのぎの解決策として使う場合です。とはいえ、型エラーになっている箇所に対して使うのはオススメしません。型が分かっている場合は型を適切につけてあげたほうが良いですし、分からない場合でもType Assertionやany型といったわからない事が少しでも伝わる書き方の方が良いです。

型と実態のズレに対してどう向き合うか

割れ窓理論

建物の窓が壊れているのを放置すると、誰も注意を払っていないという象徴になり、やがて他の窓もまもなく全て壊される

wikipediaより引用

これは型付けにも同じことが言えると思います。実態とズレた状態で時が経つと、他の修正によってズレる範囲が広がっていきます。

またチームによっては、JavaScriptやTypeScriptを学習中のメンバーがいるかもしれません。そのような場合、既に実装されたコードを参考にしつつ、他のコードを実装する場合もあるかと思います。このような場合も元のコードがズレていると問題が転移してしまいます。

ズレを放置すればするほど大きな負債となってしまうので、実装する際はできるだけズレを作らない・ズレた範囲を小さく抑え込むことを心がけていきたいところです。

こつこつと割れ窓を修理していく

既にズレてしまっている箇所はコツコツと修理していくことが大事になります。

ズレをなくしていくと他の箇所も型がつけやすくなったり、模倣されるコードとして使われた時に他の箇所に波及していくことになります。(個人的にはクロスワードの穴を埋めるような感覚だと感じます)

マイベストでは「こつこつ型追加タスク」というタスクを隔週に1度実施しています。このタスクでズレてしまった実装を適切な形に修正していき、ズレを少しずつ消していっています。

取り組み以前はany型や@ts-expect-errorといったズレてしまったコードが多数存在しており、開発が進めにくいことや思わぬ不具合に遭遇することがありました。半年ほど継続した今ではそういったことも減り、少しずつ品質も開発のしやすさが向上してきました。

全てに対して厳密な型付けができるとも限らない

実装時に適切な型や処理の書き方が思いつかないことや、型付け難易度の高いコードと出くわすことは往々としてあると思います。

「新しくズレを作らない・小さく抑え込むようにする」を心がけつつ、「できてしまったズレは少しずつ解消していく」ことを実践し、バランスをとった運用にすることが個人的には大事なのかなと思います。

おわりに

引き続きズレを解消させ、より品質を高めつつ開発のしやすいプロダクトにしていけたらと思っています!

また、株式会社マイベストでは一緒にサービスを盛り上げてくれるメンバーを募集しています!

TypeScriptの型付けはどうあるべきなのかを考えることが好きな方と是非ともお話できると嬉しいです!

ご興味を持った方はぜひ採用ページをご覧ください!

参考文献

マイベスト テックブログ

株式会社マイベストのテックブログです! 採用情報はこちら > https://my-best.com/engineer-recruitment

Discussion

anyについては一切使わず、unknownで代替するとよいかもしれません :D

コメントありがとうございます!
合わせてunkwnon型から型を絞って使えるよう修正する必要がありますが、そこがクリアできればany型を消す良い対応になるかなと思います!

@ts-expect-errorは、現在型でエラーの出ている部分の実装は適切であるが、他の部分が起因となってエラーとなっている場合や、外部ライブラリの問題で型エラーになっているなど、いずれ他の部分が直ることで解決する場合に使用すると利点を得やすいです。

anyやunknownで濁してしまうと、そのまま放置されてしまうことが多いですが、@ts-expect-errorは他の部分が直って型が適切になった場合にエラーを出して気付かせてくれるため、安全に回収できます。

注意点としては、大きなオブジェクトの手前など広範囲に及ぶような@ts-expect-errorを書くと意図しないエラーも隠蔽してしまう可能性があるので、エラーになる部分だけ1行に括りだし、コメントも一緒に添えておいたりするととてもわかりやすくなりますね。

いずれにしても使い所を誤ったら元も子もないので、型が合わないから〜とか、よくわからないまま使うものではありませんが。

コメントありがとうございます!

misukenさんの仰るとおり、@ts-expect-errorを使う時は使い方が適切であることが重要かなと私も感じます!
importのパス解決エラーなど型以外のエラーを一時的に解消させる、実装箇所は問題ないものの外部ライブラリ起因で問題が起きている等であれば適切かなと思います

記事で触れているアンチパターン等にも共通することになりますが
書く際にはその使い方が良い使い方なのか、よりbetterな書き方ができないかを模索するのが大事になりますね

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