⚠️

型安全な開発に必須の TSConfig オプション: noUncheckedIndexedAccess

2024/12/02に公開

こんにちは、ダイニーの ogino です。
ダイニーのプロダクトコードは TypeScript に統一されており、日々型の恩恵を受けて開発しています。

今回紹介するnoUnchekedIndexedAccess は、TypeScript の型チェックをより厳格にし、潜在的なバグを多数洗い出してくれる便利なコンパイラオプションです。
普通この手のオプションは strict フラグでまとめて有効化できますが、例外的に noUncheckedIndexedAccessstrict でも有効になりません。そのため、存在すら知られず無効化されたままになっていることが多いのではないでしょうか。

本記事では、以下の 2 点について解説します。

  • noUncheckedIndexedAccess によって得られるメリット
  • このオプションを有効にすることで発生しがちな型エラーの解決の仕方

noUncheckedIndexedAccess を有効化する意味

“indexed access” とは、配列やオブジェクトの要素をインデックスで参照すること (e.g. arr[0], obj["key"]) を指します。この時にインデックスの存在をチェックするよう強制するのが noUncheckedIndexedAccess の効果です。

コード例を見てみましょう。

/** Zenn のようなサービスに投稿される記事 */
interface Post {
  // 記事の投稿後に何度でも編集することができる
  edits: Edit[]
  createdAt: Date

  // ...
}

/** 記事の編集履歴 */
interface Edit {
  editedAt: Date

  // ...
}

// バグのある関数
const lastEditedAt = (post: Post): Date => {
  /** 新しい順にソートされた更新日時 */
  const editedDates = post.edits.map(edit => edit.editedAt)
    .toSorted((a, b) => b.getTime() - a.getTime());

  // 配列へのインデックスアクセス
  return editedDates[0];
};

この lastEditedAt 関数の中で、引数 post が一度も編集されていない記事の場合に editedDates は空配列になりますが、その考慮が漏れています。その場合 lastEditedAt は型ヒントに反して undefined を返しますが、上のコードはなんとコンパイルエラーになりません。
一般的に、配列の範囲外のインデックスを参照すると例外は特に発生せず、代わりに undefined が返されます。しかし TypeScript デフォルトの設定では、インデックスアクセスの返り値として推論される型に undefined は含まれません。

noUncheckedIndexedAccess を有効化するとインデックアクセスの返り値型に常に undefined が含まれるようになり、前述のコード例のバグを浮き彫りにしてくれます。 (TS Playground)

const lastEditedAt = (post: Post): Date => {
  const editedDates = post.edits.map(edit => edit.editedAt)
    .toSorted((a, b) => b.getTime() - a.getTime());

  return editedDates[0]; // 🚨 Type 'Date | undefined' is not assignable to type 'Date'.
};

noUncheckedIndexedAccess により発生する型エラーの解決法

既存のコードベースで noUncheckedIndexedAccess: true に変更すると、至る所でコンパイルエラーが発生するかもしれません。以降では、そうしたエラーへの典型的な対処法を紹介します。

(T | undefined) 型をそのまま返す

先ほどの例では、「一度も更新されていない記事の最終更新日時は定義できない」と考えれば、返り値型を Date | undefined にして解決です。

- const lastEditedAt = (post: Post): Date => {
+ const lastEditedAt = (post: Post): Date | undefined => {
    // ...
  }

すると、例えば「投稿日: 2024/11/01 最終更新日: 2024/11/10」のようなヘッダーを表示したい時は lastEditedAt を使って以下のように書けます。

const header = (post: Post): string => {
  const createdAt = `投稿日: ${post.createdAt.toLocaleDateString("ja-jp")}`;
  const editedAt = lastEditedAt(post);

  // 🚨 'editedAt' is possibly 'undefined'.
  // return `${createdAt} 最終更新日: ${editedAt.toLocaleDateString("ja-jp")}`;

  // ✅ このチェックをコンパイラが強制してくれる
  return editedAt === undefined
    ? createdAt
    : `${createdAt} 最終更新日: ${editedAt.toLocaleDateString("ja-jp")}`;
};

デフォルト値を返す

一方で、「記事を投稿した瞬間が一番最初の編集日時である」と考えると lastEditedAt を次のように定義することもできます。

  const lastEditedAt = (post: Post): Date => {
    const editedDates = post.edits.map(edit => edit.editedAt)
  	  .toSorted((a, b) => b.getTime() - a.getTime());
  
    // ✅ 記事が一度も編集されていなければ、デフォルト値として投稿日時を返す
-   return editedDates[0]
+   return editedDates[0] ?? post.createdAt;
  };

こちらの場合、「この記事は最終更新から n 年以上経っています」といった表示をする際に便利です。

length チェックを避ける

先の例では、「インデックスアクセスをした後に、その結果によって分岐」しています。一方で、「先にインデックスのバリデーションをしてからアクセス」しても挙動は同じです。

if (editedDates.length > 0) {
  return editedDates[0] // 🚨 Type 'Date | undefined' is not assignable to type 'Date'.
}
return post.createdAt;

しかしこちらのコードではコンパイルエラーが発生してしまいます。
「何故こんな簡単な推論もしてくれないのか」と思われるかもしれませんが、下記のようなケースも考慮すると、配列の length をチェックしても要素にアクセスできることが保証されないためです。

そのため、「インデックスアクセスをした後に、その結果によって分岐」する方が型推論にフレンドリーなコードになります。

return editedDates[0] ?? post.createdAt;

for ループを for…of などで置き換え

今度は別のコード例を見てみましょう。よくある C 言語スタイルの for ループで、問題なく動きはしますがコンパイルエラーが発生してしまいます。

const arr = ["foo", "bar"];

for (let i = 0; i < arr.length; i++) {
  const x = arr[i];
  console.log(x.toLocaleUpperCase());  // 🚨 'x' is possibly 'undefined'.
}

これは for...of ループで置き換えれば、インデックスアクセスがそもそも不要になり、可読性も良くなります。

for (const x of arr) {
  // ✅ x は必ず string 型
  console.log(x.toLocaleUpperCase());
}

なお、インデックス i の値も併せて必要な時は entries を使うと良いでしょう。

for (const [i, x] of arr.entries()) {
  console.log(i, x.toLocaleUpperCase());
}

「空にならない配列」を型で表す

下の例では、配列が空にならないことが事前にわかっているので、先頭要素をチェック無しに取得していますが、コンパイラにはそのことが伝わっていません。

/** Web サービスにログインするエンドユーザー */
interface User {
  /**
   * 1 つのユーザーアカウントに複数のメールアドレスを設定でき、
   * パスワードリセットなどに利用できる。
   * メールアドレスは必ず 1 つ以上存在する。
   */
  emailAddresses: string[];
  name: string;
}

const sendLoginAlert = (user: User) => {
  sendEmail({
    // メインのメールアドレスだけにメールを送信する
    toEmailAddress: user.emailAddresses[0], // 🚨 Type 'string | undefined' is not assignable to type 'string'.
    title: "新しいデバイスからログインがありました",
    // ...
  });
}

const sendEmail = (email: Email) => {
  // ...
};

interface Email {
  toEmailAddress: string;
  title: string;
  // ...
}

こうした場合は以下のような NonEmptyArray 型を定義すると、インデックスアクセスの冗長なチェックが不要になります。

/** 空にならない配列 */
type NonEmptyArray<T> = T[] & { 0: T };

interface User {
  emailAddresses: NonEmptyArray<string>;
  name: string;
}

const sendLoginAlert = (user: User) => {
  sendEmail({
    // ✅ emailAddresses[0] は必ず string 型
    toEmailAddress: user.emailAddresses[0],
    // ...
  });
}

ドメイン知識を基に型付けを見直す

NonEmptyArray 型は汎用的に使えるのが利点です。しかし、経験上 NonEmptyArray が必要になるケースの多くは、ドメインモデルの型表現に根本的な問題があります。

前述の User の例では、全てのメールアドレスが一緒くたに配列に詰め込まれているものの、実際にはその中に一つだけ特別視されている「メインのアドレス」が存在します。そして、メインのメールアドレスが配列の先頭にあるという暗黙の前提を置いています。
コードベースの全ての箇所でこの前提を守るように正しく実装されているかどうか、TypeScript の型チェッカーでは判定できません。
一方で User 型を次のように定義すれば、「メインのアドレス」の概念が一目瞭然になって、配列の並び順によるバグの心配が無くなり、危険なインデックスアクセスも不要になります。

interface User {
  primaryEmailAddress: string;
  secondaryEmailAddresses: string[];
  name: string;
}

const sendLoginAlert = (user: User) => sendEmail({
  // ✅ インデックスアクセスが不要になった
  toEmailAddress: user.primaryEmailAddress,
  // ...
});

non-null assertion

次のコードにはバグは無いように見えますが、コンパイルエラーが出てしまいます。

const getWeekDay = (date: Date): string =>
  ["日", "月", "火", "水", "木", "金", "土"][date.getDay()] // 🚨 Type 'string | undefined' is not assignable to type 'string'.

コンパイラはインデックスアクセスの結果の型を string | undefined と推論しますが、実際には Date#getDay が 0 以上 7 未満の整数を返すので、インデックスは明らかに範囲内になります。つまり、この型は string になるべきだと言えそうです。
こういった場合は non-null assertion (!) を利用することで、コンパイラの推論した型から nullundefined を強制的に取り除くことができます。

const getWeekDay = (date: Date): string =>
  ["日", "月", "火", "水", "木", "金", "土"][date.getDay()]! // ← ! を付ける

ただし ! は型安全性を壊すことができるので、使用の際にはよく注意しましょう。! はあくまでコンパイル時の型チェックを黙らせるだけで、実行時の動作に一切影響はありません。そのため、人間の推論に反して実際の値が undefined になったとしてもその場でエラーは発生せず、予期せぬところに undefined が紛れ込むことになります。

例外を投げる

注意深い方なら前節を読んで既にお気づきかもしれませんが、「Date#getDay が 0 以上 7 未満の整数を返す」とは必ずしも言えません。Invalid な Date に対して getDayNaN を返します。このとき getWeekDay の返り値は undefined になるので、結局コンパイラの方が正しかったというわけです。

const getWeekDay = (date: Date): string =>
  ["日", "月", "火", "水", "木", "金", "土"][date.getDay()]!

const weekDay = getWeekDay(new Date("invalid"))
weekDay.concat("曜日"); // 💥 実行時エラー: Cannot read properties of undefined (reading 'concat')

とはいえ、この関数に invalid date を渡すのは恐らく実装ミスでしょうし、滅多に現れないケースのためだけに返り値型を string | undefined に広げるのは不便です。
このような場合は例外を投げてしまうのが現実的な妥協案になります。

const weekDay = (date: Date): string => {
  const result = ["日", "月", "火", "水", "木", "金", "土"][date.getDay()];
  if (result !== undefined) {
    return result
  }
  throw new Error('The given date is invalid.')
}

こうすることで、型チェッカーを黙らせるのに加えて、例外的なケースが実際起こってしまった場合にもデバッグしやすくなります。

既存の大規模コードベースで noUncheckedIndexedAccess を有効化するには

ここまでで、 noUncheckedIndexedAccess により生じる型エラーへの対応方法を紹介してきました。そうは言っても現実の大規模なプロダクトコードでは、エラーの数があまりにも多すぎて全て人力で対応しきれないということも考えられます。
その場合は、全てのインデックスアクセスに機械的に non-null assertion を加えるのも一つの手です。
この方法は noUncheckedIndexedAccess の効果を全て打ち消すのと同じなので、何の意味も無いと思われるかもしれません。しかし、これには以下のような利点があります。

  • 重要なところから assertion を外して徐々に型付けを改善していくことができる
  • 新規に追加するコードで noUncheckedIndexedAccess の恩恵を受けられる
  • 型安全でない部分が assertion によって明示的になる
  • assertion は実行時の動作に影響しないのでデグレが起きない

TypeScript コンパイラー API のラッパーである ts-morph を使うと、下記のように簡単に移行スクリプトを書けます。

import { Project, SyntaxKind } from "ts-morph";

function main(tsConfigFilePath: string) {
  const project = new Project({ tsConfigFilePath });

  for (const sourceFile of project.getSourceFiles()) {
    for (const node of sourceFile.getDescendantsOfKind(SyntaxKind.ElementAccessExpression)) {
      node.replaceWithText(`(${node.getText()}!)`);
    }
  }

  project.saveSync();
}

main(process.argv[2] || "./tsconfig.json");

ただし注意が必要なのは、配列やオブジェクトの要素が nullable なケースです。例えば下のコードで x の右辺に non-null assertion を加えると、 xnumber 型になって、 noUncheckedIndexedAccess を有効化する前よりも型が悪化します。

const arr: (number | undefined)[] = [1, 2, 3, undefined];

// noUncheckedIndexedAccess を有効化する前から既に `number | undefined` 型
const x = arr[3];

これを防ぐために、前述のスクリプトに以下のような修正が必要です。

  for (const sourceFile of project.getSourceFiles()) {
    for (const node of sourceFile.getDescendantsOfKind(SyntaxKind.ElementAccessExpression)) {
-     node.replaceWithText(`(${node.getText()}!)`);
+     // このスクリプトの実行時に対象プロジェクトが `noUncheckedIndexedAccess: false` になっている前提
+     if (!node.getType().isNullable()) {
+        node.replaceWithText(`(${node.getText()}!)`);
+     }
    }
  }

まとめ

TSConfig の noUncheckedIndexedAccess を有効化すると、インデックスアクセスの返り値型に undefined が追加され、プログラマの考慮漏れをコンパイル時にあぶり出してくれます。
このオプションによる型エラーに遭遇した時は、インデックスアクセスの結果が実際に undefined となる可能性がどの程度あるかを考えて、次のように対処しましょう。

  • 通常の動作範囲内で起こり得る → 型や設計の見直し、インデックスアクセス自体の排除
  • 実装ミスやごく一部の例外的状況でしか起こらない → 例外を投げる
  • 100% 確実に起こらない → non-null assertion を付ける

また、型エラーがあまりにも多すぎて手に負えない時は、機械的に全て non-null assertion を加えるのも有効な選択肢です。

We’re hiring!

ダイニーでは、全てのプロダクトコードで noUncheckedIndexedAccess が有効化されています。
型安全な開発によってサービスの安定稼働を支えることにご興味をお持ちの方は、ぜひ以下のリンクをチェックしてみてください。

  • ソフトウェアエンジニア向けカルチャーデックはこちら
  • カジュアル面談の申し込みはこちら
  • 募集ポジション一覧はこちら

Discussion