TypeScriptの原因不明の型エラーの原因 コールバック編

2024/12/23に公開

TypeScriptでなんでこの型チェック通らないんだと思うことありますよね。
この記事ではよく見るエラーの一つであるコールバックでの型エラーについて、なぜその型エラーを通すとまずいのかを解説します。[1]

エラー

以下のように紙書籍と電子書籍のデータを題材に考えます。

// 紙書籍はISBN (International Standard Book Number) をIDとして利用する
type PaperBook = {
  type: "paper";
  isbn: string;
  // ...その他いろんなフィールド
};

// 電子書籍はDOI (Digital Object Identifier) をIDとして利用する
type EBook = {
  type: "ebook";
  doi: string;
  // ...その他いろんなフィールド
};

type Book = PaperBook | EBook;

ここで注文に対応して本のリストから本を取り出す処理を考えます。

type PaperBookId = {
  type: "paper";
  isbn: string;
};

type EBookId = {
  type: "ebook";
  doi: string;
};

type BookId = PaperBookId | EBookId;

/** 注文に対応して本のリストから本を取り出す。 */
const getBookByOrder = (books: Book[], order: { bookId: BookId }): Book | undefined => {
  if (order.bookId.type === 'paper') {
    return books.find((book) => {
      book.type === 'paper' && book.isbn === order.bookId.isbn;// Property 'isbn' does not exist on type 'BookId'.
    });
  } else {
    // 同様の処理
  }
};

特に型エラーの起きそうにないコードに見えますが、コールバック内の order.bookId.isbn の部分で Property 'isbn' does not exist on type 'BookId'. という型エラーが発生します。
VSCodeなどで変数にマウスカーソルを合わせて確認してみると、if (order.bookId.type === 'paper') {で型チェックをした直後はorder.bookIdPaperBookIdと推論されているのに、コールバックの中ではBookIdと推論されています。
このようにTypeScriptでは謎の型エラーが発生することがあります。

原因

コールバック内でorder.bookIdの型をPaperBookIdと推論するとまずいことは、以下のコールバックをfind以外の関数に渡す例を考えてみるとわかります。

const logBookIdAndModifyOrder = (order: { bookId: BookId }) => {
  if (order.bookId.type === 'paper') {
    setTimeout(() => {
      console.log(order.bookId.isbn);
    }, 1000);
  } else {
    // ...
  }

  // ここでbookIdを変更する
  order.bookId = {
    type: 'ebook',
    doi: '1234567890',
  };

  // setTimeoutのコールバック関数はこの辺りのタイミングで呼び出される
};

上記の例ではコールバック呼び出し時にはorder.bookIdEBookIdになっているので、PaperBookIdと推論しないのが正しいことがわかります。
TypeScriptには渡したコールバックが即時呼び出しされることを表す文法がなく[2]findsetTimeoutの区別がつかないため、このように型推論するしかないということになります。

解決方法

以下のようにorder.bookIdを変数に代入すると、bookIdが書き換えられる恐れがなくなり、コールバック内でもbookIdの型がPaperBookIdと推論されるようになります。[3]

const getBookByOrder = (books: Book[], order: { bookId: BookId }): Book | undefined => {
  const bookId = order.bookId;
  if (bookId.type === 'paper') {
    return books.find((book) => {
      book.type === 'paper' && book.isbn === bookId.isbn;
    });
  } else {
    // 同様の処理
  }
};

他の解決方法としてそもそもbookIdを引数にするという方法があります。
上記の例ではorderのフィールドをbookIdだけとしていましたが、実際には他のフィールドもあるはずで、スタンプ結合 (Wikipedia) になっています。
bookIdだけを引数にするとスタンプ結合が解消されるので、可能であればこちらの方が望ましい設計となります。

const getBookById = (books: Book[], bookId: BookId): Book | undefined => {
  if (bookId.type === 'paper') {
    return books.find((book) => {
      // コンパイルが通る
      book.type === 'paper' && book.isbn === bookId.isbn;
    });
  } else {
    // ...
  }
};

脇道

上記のbookIdを引数に取る場合には少し面白いところがあります。
その話の前提として、bookIdを関数内にconst, letで宣言してみましょう。

const getBookLet = (books: Book[]): Book | undefined => {
  let bookId: BookId = {
    type: 'paper',
    isbn: '1234567890',
  };
  if (bookId.type === 'paper') {
    return books.find((book) => {
      // コンパイルエラー
      book.type === 'paper' && book.isbn === bookId.isbn;
    });
  } else {
    // ...
  }
};

const getBookConst = (books: Book[]): Book | undefined => {
  const bookId: BookId = {
    type: 'paper',
    isbn: '1234567890',
  };
  if (bookId.type === 'paper') {
    return books.find((book) => {
      // コンパイルが通る
      book.type === 'paper' && book.isbn === bookId.isbn;
    });
  } else {
    // ...
  }
};

原因で述べたのと同じ理由で、letで宣言した変数は書き換えられる恐れがあるのでコールバック内ではBookIdと推論され、constで宣言した変数のみがコールバック内でもPaperBookIdと推論されています。
ここで引数にbookIdを取る場合を考えると、引数は書き換え可能なのでletと同様にコンパイルエラーが発生しそうですが実際には発生しません。
より詳細な挙動は以下のコードで確認できます。

const getBookById = (
  books: Book[],
  bookId: BookId
): Book | undefined => {
  if (bookId.type === 'paper') {
    return books.find((book) => {
      // コンパイルが通る
      book.type === 'paper' && book.isbn === bookId.isbn;
    });
  } else {
    // 同様の処理
  }
};

const getBookByIdAndModify = (
  books: Book[],
  bookId: BookId
): Book | undefined => {
  if (bookId.type === 'paper') {
    return books.find((book) => {
      // コンパイルエラーが発生する
      book.type === 'paper' && book.isbn === bookId.isbn;
    });
  } else {
    // 同様の処理
  }

  bookId = {
    type: 'ebook',
    doi: '1234567890',
  };
};

引数に代入を行うとletの場合と同様に振る舞い、引数に代入を行わないとconstの場合と同様に振る舞っています。
TypeScriptは利便性のためか、引数については代入が実際に行われているかによってスマートにチェックするようです。

まとめ

TypeScriptでよく見かけるコールバックの型エラーの原因は呼び出しタイミングによる書き換え可能性が原因だということを解説しました。
これでTypeScriptの挙動の謎が一つ解けると幸いです。[4]

脚注
  1. TypeScriptのここでドキュメントされている挙動ですという形の説明はありません ↩︎

  2. 仮にコールバックを即時呼び出しすることを表す文法があったとしても、関数がコールバックとしてしか使われないことをコンパイラが確認する必要があるなど、精密な型推論のためには他の障壁もありますが ↩︎

  3. 今回のケースではfindの中で分岐を行うことでも解決できますが、本筋とは関係ないので省略 ↩︎

  4. 他にもTypeScriptの挙動で遊んでいるスクラップがあります TypeScriptの挙動メモ ↩︎

GitHubで編集を提案
Aidemy Tech Blog

Discussion