vue-infinite-loadingをTypeScriptで使うQuickHack、あるいはinstanceofが使えないクラスの型の保証について

よく見かける無限スクロール。使う用事があったのでVueで簡単に実装できる方法を調べてみました。`vue-infinite-loading`というのがポピュラーなようです。

```terminal
$ npm install vue-infinite-loading
```

# 環境

- Vue 2.6.11
- Vuetify 2.3.18
- TypeScript 4.0.5

# コード

## 前提
サーバーサイドにはページング機能付きで楽曲情報(Song)を返すAPIがある
ページサイズ16で下方向無限スクロールを実装する
セレクトボックスの値が変更されると、それまでに読み込んだデータは破棄して再度1ページ目から表示開始
エラー処理は省略

```vue
<template>
  <v-container>
    <v-row>
      <v-vol>
        <v-select :items="selectItems" v-model="selectedGroup" @change="onChangeGroup"/>
      </v-vol>
    </v-row>
    <v-row>
      <v-col v-for="item in songList" key="item.id>
        <v-img :src="item.coverImage"/>
      </v-col>
      <InfiniteLoading ref="infiniteLoading" @infinite="onEndOfPage"/>
    <v-row>
  </v-container>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import axios, { AxiosResponse, AxiosError } from 'axios';
import InfiniteLoading, { StateChanger } from 'vue-infinite-loading';

interface Song {
  title: string;
  artist: string;
}

@Component({
  components: {
    InfiniteLoading
  }
})
export default class InfiniteLoadingTest extends Vue {
  songList: string[] = [];
  page = 0;
  gotAllData = false;

  selectItems: string[] = ['μ\'s', 'Aqours', '虹ヶ咲', 'Liella!'];
  selectedGroup = '';

  get pageSize() {
    return 16;
  }

  async mounted() {
    const params = {
      offset: 0,
      limit: this.pageSize
    };
    const songList: AxiosResponse<Song[]> = await axios.get<Song[]>('https://example.com/songs', {params: params});
    this.songList = res.data;

    let loading: InfiniteLoading | null = null;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const isInfiniteLoading = (x: any): x is InfiniteLoading => (x !== null && typeof x === 'object' && typeof x.distance === 'number');
    if (isInfiniteLoading(this.$refs.infiniteLoading)) {
      loading = this.$refs.infiniteLoading;
    }

    if (!loading) {
      throw 'にゃーん';
    }

    if (res.data.length < this.pageSize) {
      this.gotAllData = true;
      loading.stateChanger.complete();
    } else {
      loading.stateChanger.loaded();
    }
  }

  async onEndOfPage($state: StateChanger): void {
    if (this.gotAllData) {
      return;
    }
    this.page++;

    const params = {
      offset: this.page * this.pageSize,
      limit: this.pageSize
    };
    const songList: AxiosResponse<Song[]> = await axios.get<Song[]>('https://example.com/songs', {params: params});
    this.songList = this.songList.concat(res.data);
    if (res.data.length < this.pageSize) {
      this.gotAllData = true;
      $state.complete();
    } else {
      $state.loaded();
    }
  }

  async onChangeGroup() {
    let loading: InfiniteLoading | null = null;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const isInfiniteLoading = (x: any): x is InfiniteLoading => (x !== null && typeof x === 'object' && typeof x.distance === 'number');
    if (isInfiniteLoading(this.$refs.infiniteLoading)) {
      loading = this.$refs.infiniteLoading;
    }

    if (!loading) {
      throw 'にゃーん';
    }

    loading.stateChanger.reset();

    const params = {
      offset: 0,
      limit: this.pageSize,
      group: this.selectedGroup
    };
    const songList: AxiosResponse<Song[]> = await axios.get<Song[]>('https://example.com/songs', {params: params});
    this.songList = songList.data;
    this.gotAlldata = songList.data.length < this.pageSize;
    if (this.gotAllData) {
      loading.stateChanger.complete();
    } else {
      loading.stateChanger.loaded();
    }
  }
}
</script>
```

全部の説明書き始めると長くなりすぎるので悩んだところだけ説明します。

`this.$refs`を通して要素に直接アクセスするのはTypeScriptだと鬼門だと思ってるんですが、今回もInfinoteLoadingの状態管理のために要素アクセスが必要で、それも普段使っている手が通じなかったので厄介でした。

よくやるのはいろいろな型が入っている可能性があるオブジェクトに対して

```typescript
const isVue = (x: unknown): x is Vue => x instanceof Vue;
isVue(someObjectLikeVue);
```

のようにしてタイプガードで型を判定・保証するものですが、今回`InfiniteLoading`であることを確認しようとして同様に実装すると

```sh
x instanceof InfiniteLoading;
 => TypeError: Right-hand side of 'instanceof' is not callable
```

はて？

型定義を確認すると

```typescript:index.d.ts
export default class InfiniteLoading extends Vue {
  // The trigger distance
  distance: number;

  // The load spinner type
  spinner: SpinnerType;

  // The scroll direction
  direction: DirectionType;

  // Whether find the element which has `infinite-wrapper` attribute as the scroll wrapper
  forceUseInfiniteWrapper: boolean | string;

  // Infinite event handler
  onInfinite: ($state: StateChanger) => void;

  // The method collection used to change infinite state
  stateChanger: StateChanger;

  // Slots
  $slots: Slots;
  
  static install: PluginFunction<InfiniteOptions>;
}
```

ごく普通のクラスに見えるけどこれがcallableではないということらしい。

JavaScriptで書くなら`instanceof`の右辺はコンストラクタオブジェクトである必要がある。わかる。ただそれがTypeScriptだとどうなるのか、正直よくわかっていません。

で、かなり無理矢理回避しているのがコード内に何箇所かあるこの部分

```typescript
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const isInfiniteLoading = (x: any): x is InfiniteLoading => (x !== null && typeof x === 'object' && typeof x.distance === 'number');
    if (isInfiniteLoading(this.$refs.infiniteLoading)) {
      loading = this.$refs.infiniteLoading;
    }
```

TypeScriptでは`catch`以外で`any`使ったら負けですが、ここは本当にどうしようもないので許してください、が1行目。
あとはnullでなく、何らかのオブジェクトであり、number型の`distance`プロパティを持っていればまあ多分`InfiniteLoading`だろう、ヨシ！

この方法が厳密に型安全かと言われるとそうではないんですが、ライブラリ側のTypeScriptサポートが甘いと起こり得る問題のような気がします。似たようなハマり方をした際は参考としてどうぞ。