🏷

@expo/vector-iconsのアイコン名を型安全に扱う

2021/04/19に公開

追記:当初はglyphMapを使う記事として書いていましたが、ComponentPropsを使ったほうが楽そうなので、記事のタイトルを変更しました。記事の大筋は変わってません。


React Nativeでアイコンを扱いたいとき、react-native-vector-iconsを使っている方は多いと思います。このライブラリは、Font AwesomeIoniconsMaterialIconsMaterialCommunityIconsなど、多彩なアイコン集をバンドルしています。おかげで、アプリ開発で要求されるアイコンの大部分をこのライブラリで賄える現場もあるのではないでしょうか。

ところで、ExpoでReact Native開発を行う場合には、少し違ったライブラリが用意されています。今回言及する@expo/vector-iconsです。Expoには、アプリの起動時などにJavaScriptコードやアセット(画像など)をサーバーから取得して、自己アップデートする機能(OTA Updates)がある関係で、通常のReact Nativeから見ると少し違ったアセット管理の仕組みを持っています。@expo/vector-iconsは、この少し特殊なアセット管理の仕組みに適応するようにreact-native-vector-iconsをラップしたライブラリです。

本記事では、TypeScriptでアイコン用コンポーネントを扱う際の使い勝手の観点から2つのライブラリを比較した後、@expo/vector-icons特有のTIPSを紹介します。

react-native-vector-iconsのnameはstring型

さて、react-native-vector-iconsの型情報は@types/react-native-vector-iconsで管理されていますが、この型定義において、 name propsはstring型[1]です。

つまり、次のようなケースで、 "rocket" という名前の正しさは動かしてみるまでわからないのです。

react-native-vector-icons
import Icon from 'react-native-vector-icons/FontAwesome';

const myIcon = <Icon name="rocket" size={30} color="#900" />;

もちろん、注意深く実装すれば問題ありませんが、残念ながら人類は注意深くありません。

型安全を愛する皆さんであれば、 name が受け入れうる文字列を文字列リテラル型のユニオン型で列挙してほしいですよね。そうすれば、 name の誤りをtscで検出できます。

@expo/vector-iconsはTypeScript製でnameに型がある

というわけで、実際に name 文字列リテラル型のユニオン型で列挙しているのが、@expo/vector-iconsです。

本来はreact-native-vector-iconsのラッパーでしかなかったのですが、2018〜2019年にExpoプロジェクトがTypeScriptにリライトされた[2]際に@expo/vector-iconsもTypeScriptに書き換えられました[3]。その後、string型だった name をユニオン型に置き換えるコミットが2020年12月に行われています[4]。例えば、FontAwesomeの型定義は次のようになりました[5]

FontAwesome.d.ts
declare const _default: import("./createIconSet").Icon<"link" | "search" | "image" | "header" | "key" | "code" | "map" | "table" | "th" | "circle" | "filter" | "stop" | "forward" | "retweet" /* 長いので省略します */>;
export default _default;

どこから持ってきた型情報なのかいまいち不明な部分もあるのですが、react-native-vector-iconsが持っているアイコン名のJSONファイル[6]から生成したような話をどこかで見かけた気がします。

さて、この型定義のおかげで、 name の誤りを検出できるようになりました。実際の動作を見てみましょう。次のExpo Snackにサンプルコードが置いてあります。

まずは、正しく "rocket" と入力した場合です。エラーは出ず、画面にもロケットのアイコンが表示されていますね。

正しくrocketと入力した場合

これを編集して、 "rocke" にしてみます。すると、型と値の不整合によるエラーが表示されます(もちろんアイコンは出ません)。

nameの型がエラーになった

素晴らしい! これこそが型安全を好む私たちが追い求めていた世界です。

というわけで、@expo/vector-iconsにはTypeScriptの観点でreact-native-vector-iconsにはないアドバンテージが存在するのでした。

nameに変数を渡したいときの型をどうするか

さて、 name にちゃんと型が付いているのは良いことなのですが、少し困るタイミングがあります。次の例のように name に変数を渡したい場合です。

export default function App() {
  let iconName: string; // string型なのでnameの型と噛み合わない
  if (/* 条件文 */) {
    iconName = 'rocket';
  } else {
    iconName = 'map';
  }

  return (
    <View style={styles.container}>
      <Icon name={iconName} size={30} color="#900" />
      {/*   ^^^^ Type Error!!!! */}
    </View>
  );
}

この場合、 name はエラーになります。 iconName がstring型で、 name が求めるユニオン型と噛み合わないからです。

愚直な対応としては、 iconName の型をユニオン型にしてしまう、という方法があります。

-   let iconName: string;
+   let iconName: 'rocket' | 'map';

これなら name の部分型として成立しますので、型チェックはクリアできます。ただ、すごく面倒です。

本来であれば、アイコン名のユニオン型を表す型情報がexportされていれば嬉しいですね。ただ、残念ながら、前述のFontAwesome.d.tsを見ていただければ分かるとおり、ジェネリクスの型パラメータとしてベタ書きされているような状況ですので、気の利いた型としては公開されていません。

アイコン名の型を頑張って作る

とはいえ定義はされてるんだから、頑張ればどこかから引きずり出せるやろ!ということで、頑張ってみた結果がこちらになります。

アイコン名の型を引き出す魔法のスニペット
import { FontAwesome } from '@expo/vector-icons';

type GlyphNames = ComponentProps<typeof FontAwesome>['name'];
// type GlyphNames = keyof typeof FontAwesome.glyphMap; // 旧実装

各アイコンセットの共通プロパティとして生えている glyphMap というオブジェクトがアイコン名をキーとして持っているので、 keyof でキーだけを引っ張り出した形になります。(←旧実装の話)

まずは ComponentProps<typeof FontAwesome> でFontAwesomeコンポーネントのprops一覧のマップを取り出し、そこから ['name']name propsの型だけを取り出す手法です。( @soh335さんに教えてもらいました🙏 )

GlyphNames にマウスオーバーしてみると、ちゃんとユニオン型が入っていることがわかります。

GlyphNamesの型

というわけで、任意のアイコン名を変数で渡したい場合も、正しい型付けで型注釈を書けるようになりました。

サンプルの最終形は次の通りです。

App.tsx
import * as React from 'react';
import { View, StyleSheet } from 'react-native';
import { FontAwesome as Icon } from '@expo/vector-icons';

export type GlyphNames = ComponentProps<typeof Icon>['name'];

export default function App() {
  let iconName: GlyphNames; // FontAwesomeのすべてのアイコン名を受け付けられる
  if (/* 条件文 */) {
    iconName = 'rocket';
  } else {
    iconName = 'map';
  }

  return (
    <View style={styles.container}>
      <Icon name={iconName} size={30} color="#900" />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
});

まとめ

@expo/vector-iconsのアイコン名を型安全に扱う方法について紹介しました。

動的にアイコンを切り替えたい場面では重宝するテクニックなので、頭の片隅に置いておくとよさそうです。

脚注
  1. https://github.com/DefinitelyTyped/DefinitelyTyped/blob/42716053944a355aa3e1804be052112097c63f7b/types/react-native-vector-icons/Icon.d.ts#L26 ↩︎

  2. https://github.com/expo/expo/issues/2164 ↩︎

  3. https://github.com/expo/vector-icons/pull/84 ↩︎

  4. https://github.com/expo/vector-icons/commit/48761c0222581c3411496cbc94a3f09cae5caad8 ↩︎

  5. https://github.com/expo/vector-icons/blob/630e2bb16cd255b19ebad7afd8b14bfdcf67b618/build/FontAwesome.d.ts ↩︎

  6. https://github.com/expo/vector-icons/blob/630e2bb16cd255b19ebad7afd8b14bfdcf67b618/build/vendor/react-native-vector-icons/glyphmaps ↩︎

Discussion