5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Gatsby のチュートリアルを TypeScript で書いているときに躓いたこと

Posted at

はじめに

このチュートリアルを TypeScript + Yarn で書いていく時に悩んだ部分を書き記す。

なお、執筆者はあまり TypeScript に詳しくない。 言語的には強い静的型付言語である Scala や Haskell などを多少やっているので、何を書いているのかはある程度分かるのだが、その一方で JavaScript / TypeScript 特有の開発のための環境設定・ツールには疎いためにこの部分に対する知識が弱い。 この前提で読んでもらえると、なぜ躓いたかが理解してもらいやすいと思う。

検証環境

  • Ubuntu 20.04 LTS + VSCode
  • node: n でインストールした v16.14.2 (with npm 8.5.0)
  • yarn: v1.22.17
  • typescript: v4.6.3
  • gatsby: v4.12.1

ESLint + Prettier の環境設定

ESLint と Prettier を導入したが、これをどのように動作させるかで迷った。
ネット上の記事を読むとこれらを「連携させる」とあったので、この言葉のイメージから「ESLintを起動させれば、そのフォーマッタとして Prettier が起動されて一括チェックされる」というものを想像していた。
しかし、なかなかこの設定ができず、調べていると以下の記事に記載があるように「各プロジェクトの熟成によって ESLint と Prettier は別に稼働させるようになった」のが現状ということのようなので、今回は以下の設定を行った。 実際、別々のツールであるのであれば、個別にパイプラインのように稼働させる方が、個人的にはしっくりきた。

  • Prettier をフォーマッタとして利用するため、ESLint のフォーマッタを無効にする eslint-config-prettieryarn add して .eslintrc の extends の末尾に prettier を追加する
    • 少し昔の記事を見ると prettier/@react などの記載があることもあるが、これらはすべて prettier の記載のみでOKになった模様。 これらを書くと実行時に統合されたというエラーメッセージが出る
  • 好きな .prettierrc を記載する
    • このファイルによる自動フォーマットは、今回利用した VSCode のプラグイン経由で保存時に自動的にコードフォーマットを修正するようにするため --write オプションや --fix オプションは今回利用しない
  • コミット後にソースコードがルール通りであるか否かをチェックするコマンド群 yarn pre を実装する
    • 今回は package.json の scripts に "scripts": { "pre": "prettier --check src/ && eslint --max-warnings 0 src/ && yarn typecheck" } を記載
    • ここでは Prettier によるフォーマットチェック、ESLint による警告を許容しない構文チェック、TypeScript による型チェック (tsc --noEmit) を順に実施し、1つでも問題があった場合は 0 以外を返すようにしている

Part 1: Create and Deploy Your First Gatsby Site

特になし。

Part 2: Use and Style React Components

Link タグを使おうとしたら TypeScript のコンパイルエラー

これは別記事にて原因を記載。 記事執筆時点では @types/react のバージョンを18未満にすればOK.

型定義をつける

any だと (Lint的な意味での) エラーになるため、引数に明示的な型をつけてやればOK。 ここでは interface を使っているが、type でも大丈夫。

import * as React from 'react';
import { Link } from 'gatsby';

interface LayoutArgument {
  pageTitle: string;
  children: React.ReactNode;
}

const Layout = ({ pageTitle, children }: LayoutArgument) => {
  return (
    <div>
      <title>{pageTitle}</title>
      <nav>
        <ul>
          <li>
            <Link to="/">Home</Link>
          </li>
          <li>
            <Link to="/about">About</Link>
          </li>
        </ul>
      </nav>
      <main>
        <h1>{pageTitle}</h1>
        {children}
      </main>
    </div>
  );
};

export default Layout;

CSS をインポートする

上記チュートリアルの JavaScript では css ファイルのクラスを読み取って、div 要素の className にバインドしている。
しかし、TypeScript の場合、css の内容と型定義をちゃんと紐づけてやらないといけない。
方針として、以下の2つがあるので、プロジェクトの用途に応じて選ぶと良いだろう。

A. CSS をインポートだけして型安全性を捨てる

import './layout.module.css';

// ... 略
<div className="container">

単に css ファイルをインポートだけして、className を固定値で指定するのであればこの方法で良い。
しかし、この方法では layout.module.css 内に .container が定義されている保証はないため、型安全ではない。

B. CSS から型定義ファイル (.d.ts) を自動生成して利用する

以下の記事であるように、CSS 更新の度に都度 TypeScript の .d.ts ファイルを生成し、それを元に型安全に使う方法がある。

ここでは、yarn add -D typed-css-modules で型定義自動生成ツールをインストールし、yarn tcm -c --watch [ターゲット] などで .css ファイルを保存したときに自動的に .d.ts ファイルを作成するようにする。

今回の場合は、以下のような layout.module.css.d.ts が生成された。

layout.module.css.d.ts
declare const styles: {
  readonly "container": string;
};
export = styles;

型定義がちゃんとしていれば、以下のように型安全に css のクラスをインポートできる。

import { container } from './layout.module.css';
// 中略
<div className={container}>

ここでは tcm-c オプションをつけているが、これをつけると CSS 記載のハイフン付きクラス名をキャメルケースに変換してくれる ( my-class -> myClass )。 TypeScript のプロパティ名としてハイフンは使えないため、このオプションは付与して型定義を生成することを推奨。

また、自動生成されたファイルはこちらで用意している Prettier や ESLint の指定フォーマットに対応していないはずなので、--fix による自動修正をかけるか、 .prettierignore.eslintignore によって除外しておくのが良いだろう。
今回は Prettier によるコード整形と (コード整形をプラグインで無効化した) ESLint で構文チェックを行っているため、 .prettierignore*.d.ts を無効にすることにした。
私的には自動生成されたコードはそのままの方が良いと考えているため除外の方法を取った。

Part 3: Add Features with Plugins

特になし

Part 4: Query for Data with GraphQL

.mdx ってどんなファイル?

Part 4 では空の .mdx ファイルを作るが、これはどんなファイルなのだろうか?
記事執筆時点でググると Extended Media Descriptor や Media Data eXtended のメディアファイルだと出てきてしまうが、これではない。

ここでの MDX ファイルはマークダウンファイル (.md) の拡張 (eXtended)である。
もしくはマークダウンに追加で JSX を書けるという意味での MD + JSX = MDX のような理由で命名されたものかもしれない。

いずれにせよ、データソースとして Markdown ファイル (+α) を読み込もうとしていることを理解すれば問題ない。

GraphQL 関連の型付について

以下のコードの data に型をつけて型安全に扱いたい。

blog.js
import * as React from 'react'
import { graphql } from 'gatsby'
import Layout from '../components/layout'

const BlogPage = ({ data }) => {
  return (
    <Layout pageTitle="My Blog Posts">
      <ul>
      {
        data.allFile.nodes.map(node => (
          <li key={node.name}>
            {node.name}
          </li>
        ))
      }
      </ul>
    </Layout>
  )
}

export const query = graphql`
  query {
    allFile {
      nodes {
        name
      }
    }
  }
`

export default BlogPage

この解決方法として gatsby-plugin-typegen を利用する。

このプラグインによって、(デフォルトでは) src/__generated__/ 以下に GraphQL の型情報を自動生成する。 この情報の生成は gatsby build あるいは gatsby develop のタイミングになる。

このプラグインによって、型情報を保持した namespace GatsbyTypes を使えるようになる。 GraphQL の query に名前を書くことで型を自動定義してくれるため、上記のコードは以下のように書き直せる。

blog.tsx
import * as React from 'react';
import { graphql } from 'gatsby';
import Layout from '../components/layout';

interface BlogArgument {
  data: GatsbyTypes.BlogPostsQuery;
}

const BlogPage = ({ data }: BlogArgument) => {
  return (
    <Layout pageTitle="My Blog Posts">
      <ul>
        {data.allFile.nodes.map(node => (
          <li key={node.name}>{node.name}</li>
        ))}
      </ul>
    </Layout>
  );
};

export const query = graphql`
  query BlogPosts {
    allFile {
      nodes {
        name
      }
    }
  }
`;

export default BlogPage;

こちらも自動生成されるファイルがあるので .prettierignore.gitignore で必要に応じて無効化した。

型の再生成タイミングについて

記事執筆時点の gatsby-plugin-typegen の最新安定バージョン v2.2.4gatsby develop --verbose で実行してトレースした限り、型ファイルを生成してくれるのは初回起動時の1度のみ だった。 コードを修正して保存した時、同様に型定義も作り直してほしいのだが、どうもそれができていないらしい。 もちろん、都度 gatsby develop を再起動させれば型も再生成できるため致命的な問題ではないが、開発時にそれはやりたくないので対応方法がほしい。

原因は多分このあたりで議論されている内容だとは思うが、どうも Gatsby のイベントライフサイクルの変化にプラグインが対応しきっていないようだ。

対策としては、Issue内でも述べられているように安定版ではなくRC版のv3を利用するという方法がある。

もちろん、RC版であるため問題がある可能性はあるが、チュートリアル程度の簡易的な要素で使う限りでは gatsby-plugin-typegen@3.0.0-rc.0 を利用すれば gatsby develop を実行していてクエリを変更したときに、併せて型定義ファイルも変更してくれるのを確認できた。

…のだが、GraphQL のクエリを書いているコードがコンパイルエラーになってしまった場合、例え v3 系を使っても GraphQL 部分の型を生成してくれないケースを確認した。 ので、ここはもう気にしない方がいいのかもしれない。

Part5: Transform Data to Use MDX

@mdx-js/mdx@mdx-js/react でバージョンを指定しないとコンパイルエラーになる

チュートリアルではわざわざバージョン指定してあったが、初回はバージョン指定をせずに yarn add した。 しかし、その場合以下のようなエラーが gatsby develop 時に出力された。

Error in "/home/********/node_modules/gatsby-plugin-mdx/gatsby-node.js": require() of ES Module
/home/********/node_modules/@mdx-js/mdx/index.js from /home/********/node_modules/gatsby-plugin-mdx/utils/mdx.js not
supported.
Instead change the require of index.js in /home/********/node_modules/gatsby-plugin-mdx/utils/mdx.js to a dynamic import() which is
available in all CommonJS modules.

チュートリアルの更新履歴を見たが、記事執筆時現在では v1 の指定が必須のようだ。 2022/02 に更新されているので、チュートリアルの更新もかなり活発であることが分かる。

更新日時を記事に表示するための手順 (主に型付)

チュートリアルのメインでは無いが、Pro Tips として以下のようなクエリによってファイルの更新日時を取得できるという記載がある。

query MyQuery {
  allMdx {
    nodes {
      parent {
        ... on File {
          modifiedTime(formatString: "MMMM D, YYYY")
        }
      }
    }
  }
}

これを実際に組み入れて見ると、以下のような型が生成される(実際にはそれ以外のチュートリアルの内容やこちらで命名した名前も含まれている)。

type BlogPostsQuery = {
  readonly allMdx: {
    readonly nodes: ReadonlyArray<{
      readonly id: string,
      readonly body: string,
      readonly frontmatter: {
        readonly date: string | undefined,
        readonly title: string
      } | undefined,
      readonly parent: { readonly modifiedTime: string } | {} | undefined
    }>
  }
};

ここでは parent を使いたいので、チュートリアルにならって簡単に node.parent.modifiedTime と書きたい。 しかし、型をよく見ると {} が Union Type として入ってしまっているので、modifiedTime に対してアクセスすると型エラーとなってしまう。

そこで変更日時の表示部分を別の JSXElement に切り出すことで対処する。 これは、更新日時がなければ表示しない、という要件を実現するためにも別の要素に切り出していいところだろう。

新しい要素では上記クエリの node を受け取るようにする。 そのために、既存の型定義から BlogNode というエイリアス型を作成する。 その方法は以下の通り。

type BlogNode = GatsbyTypes.BlogPostsQuery['allMdx']['nodes'][number];

具体的には、

  • ある型の中にあるプロパティが持っている型を取得する場合、['プロパティ名'] という方法を利用する
    • VSCode でサジェストされた方法
  • Arrayの要素で指定された型を取得する場合 [number] を付与する

という方法で既存の型から情報を取得できる。

これらの詳細は以下を参照のこと。 Lookup Types と呼ばれているようだ。

これを使って、JSXElement関数である ModifiedLabel を作成する。

const ModifiedLabel = ({ node }: { node: BlogNode }) => {
  const parse = (node: BlogNode) => {
    if (!node.parent) return '';
    const typedParent = node.parent as { readonly modifiedTime: string };
    if (typeof typedParent.modifiedTime !== 'string') return '';
    return typedParent.modifiedTime;
  };
  const parsed = parse(node);
  if (!parsed) return null;
  return <span>(Modified: {parsed})</span>;
};

注意点は以下の通り。

  • JSXElement の引数としてオブジェクトを取得するので、node ではなく { node } として受け取る関数を定義すること
  • !node.parent でチェックしても、node.parentmodifiedTime プロパティを持っているか分からないため、型ガード (Typed Guard) と呼ばれる手法を用いて modifiedTime が存在するか否かをチェックした後に、その値を取得する関数 parse を記載している。
  • const typedParent = node.parent as { readonly modifiedTime: string }; で強制キャストを行っている
  • if (!parsed) return null; でプロパティが取得できなかった場合は 空の JSXElement (=null) を返すようにする

Part 6: Create Pages Programmatically

mdx や frontmatter が null 許容型として定義されている

こちらのコンテンツで各コンテンツをページ別にレンダリングする際、以下のようなクエリを記載する。

  query Post($id: String) {
    mdx(id: { eq: $id }) {
      frontmatter {
        title
        date(formatString: "MMMM D, YYYY")
      }
      body
    }
  }

これを実際に tsx 上で記載するために data.mdx.frontmatter などと記載したいのだが、このクエリから型生成をすると mdxfrontmatter 自体が null 許容型 として定義されており、undefined を取り得る型として生成される。 これは クエリの引数に存在しない $id を渡せばデータが取得できないため、型生成としては至極まっとうな話である。
そのため、JSX内にそのまま書こうとすると data.mdx?.frontmatter?.title のように書く必要がある。 しかし ?. のオプショナルチェイニングは直前の要素が null や undefined の場合、最終評価が undefined となってしまうので、string を要求する引数へ直接渡すことができない。

これらのコンテンツが正常に取得できない場合、データの不備として、この要素は正常にレンダリングできなくても良い仕様でも問題だろう。

そこで、事前にガード句を入れることで、そこより後でそれぞれのデータが存在することを保証することができる。 以下では BlogPost の最初にガード句を入れることで、以後の JSX 記述を ? なしで記載できている。

個人的には TypeScript の型チェックは、これらの非nullチェックが強く、使っていて心地よい。

src/pages/blog/{mdx.slug}.tsx
import * as React from 'react';
import { graphql } from 'gatsby';
import { MDXRenderer } from 'gatsby-plugin-mdx';
import Layout from '../../components/layout';

type PostArgument = {
  data: GatsbyTypes.PostQuery;
};

const BlogPost = ({ data }: PostArgument) => {
  if (!data.mdx || !data.mdx.frontmatter) return null;
  return (
    <Layout pageTitle={data.mdx.frontmatter.title}>
      <p>{data.mdx.frontmatter.date}</p>
      <MDXRenderer>{data.mdx.body}</MDXRenderer>
    </Layout>
  );
};

export const query = graphql`
  query Post($id: String) {
    mdx(id: { eq: $id }) {
      frontmatter {
        title
        date(formatString: "MMMM D, YYYY")
      }
      body
    }
  }
`;

export default BlogPost;

Part 7: Add Dynamic Images from Data

gatsby-plugin-imagegetImage 引数の型合わせができない

以下の GraphQL クエリで画像を取得するが、この hero_imagegetImage で読み込もうとすると型エラーになる。

  query($id: String) {
    mdx(id: {eq: $id}) {
      body
      frontmatter {
        title
        date(formatString: "MMMM DD, YYYY")
        hero_image_alt
        hero_image_credit_link
        hero_image_credit_text
        hero_image {
          childImageSharp {
            gatsbyImageData
          }
        }
      }
    }
  }
import { getImage } from 'gatsby-plugin-image';

// ...中略

const BlogPost = ({ data }: PostArgument) => {
  if (!data.mdx || !data.mdx.frontmatter) return null;
  // エラー1
  const image1 = getImage(data.mdx.frontmatter.hero_image);

  // エラー2
  if (!data.mdx.frontmatter.hero_image) return null;
  const image2 = getImage(data.mdx.frontmatter.hero_image);

  // ...後略
});

エラーメッセージ1
型 '{ readonly id: string; readonly childImageSharp: { readonly gatsbyImageData: any; } | undefined; } | undefined' の引数を型 'ImageDataLike' のパラメーターに割り当てることはできません。
型 'undefined' を型 'ImageDataLike' に割り当てることはできません。ts(2345)

エラー1はわかりやすく、hero_image が undefined の可能性があるから getImage の引数に許容できないというエラー。 それを受けてエラー2のパターンに undefined の可能性を除外して受け渡してみても以下の通りエラー。

エラーメッセージ2
型 '{ readonly childImageSharp: { readonly gatsbyImageData: any; } | undefined; }' の引数を型 'ImageDataLike' のパラメーターに割り当てることはできません。
型 '{ readonly childImageSharp: { readonly gatsbyImageData: any; } | undefined; }' を型 'FileNode' に割り当てることはできません。
型 '{ readonly childImageSharp: { readonly gatsbyImageData: any; } | undefined; }' には 型 'Node' からの次のプロパティがありません: id, parent, children, internal ts(2345)

これは、getImage に関する型を見ていくとわかりやすく、

import { Node } from "gatsby";

export declare type FileNode = Node & {
    childImageSharp?: IGatsbyImageDataParent<Node>;
};
export declare type ImageDataLike = FileNode | IGatsbyImageDataParent | IGatsbyImageData;
export declare const getImage: (node: ImageDataLike) => IGatsbyImageData | undefined;

今回の想定では FileNode を想定しているが、これは gatsby の Node 型に加えて、childImageSharp? をプロパティとして持っている必要がある。 今回 GraphQL から生成された hero_image の型情報は readonly hero_image: { readonly childImageSharp: { readonly gatsbyImageData: any; } | undefined; } しか生成されていないため、gatsby の Node で定義されている必須プロパティの id, parent, children, internal が存在していない。 そのため、型の不一致が起きている。

今回は以下のように、一度 unknown を経由して不安定なキャストを伝えた上1で、強制的に Node にキャストすることで対処した。 image は無いことも考えられるので、その場合は条件付きレンダーの実装で GatsbyImage を表示しないようにしている。

ただ、型安全をだいぶ捨てるやり方なので、もし他にうまい方法があれば、教えてほしい。

const BlogPost = ({ data }: PostArgument) => {
  if (!data.mdx || !data.mdx.frontmatter) return null;
  const image = getImage(data.mdx.frontmatter.hero_image as unknown as Node);
  return (
    <Layout pageTitle={data.mdx.frontmatter.title}>
      <p>{data.mdx.frontmatter.date}</p>
      {image && (
        <GatsbyImage
          image={image}
          alt={data.mdx.frontmatter.hero_image_alt ?? ''}
        />
      )}
      <MDXRenderer>{data.mdx.body}</MDXRenderer>
    </Layout>
  );
};

Part共通要素

何故か gatsby-config.js がないと実行時エラーが発生する

gatsby develop を実行すると以下のようなエラーが発生することがあったが、発生条件はよく分かっていないが、コンパイルエラーが起きたときに何らかの理由でロストしていたように見える。

ERROR #10124  CONFIG

It looks like you were trying to add the config file? Please rename "gatsby-config.ts" to "gatsby-config.js"

Error: Cannot find module '/home/********/gatsby-config'

設定ファイルは .cache/compiled/gatsby-config.js に展開されているのだが、どうもこのキャッシュが消えてしまうことがある模様。 かつ、gatsby-config.ts の変更がない場合は仕組み上キャッシュを再生成されない(ように見える)なので、ファイルが発見できないというエラーになる。

解決方法としては、キャッシュ部分を再生成すればこの問題は解決する。 キャッシュ再生成として、gatsby-config.ts あるいは gatsby-config.js を保存し直せば一応キャッシュは再生成される。 あるいは、キャッシュを全削除する gatsby clean を利用する。

おわりに

既存のコードを書いていくなかで、Gatsby で何ができるのかを学べたのもあるが、それ以上にTypeScriptの型合わせの実践ができたと思う。 特に、

  • 型情報の抽出は多くの場合プラグインがあるので、その調査 + 利用をまず検討する
    • 今回の例だと typed-css-module や gatsby-plugin-typegen など
  • 環境として VSCode を使ったが、かなり型補完がききやすく、必要な型情報を探しやすい
  • 必要な型を自前で定義して当てはめられる
  • どうしようもない場合は unknown や any、as によるキャストや型ガードを使って局所的に対応する

ということを学べた。

と同時に、本質では無いところ(特に型定義がそもそも難しい GraphQL のクエリ結果を型付で利用する場合)で躓いている実感も同時にあり、実際に利用する場合にどうするかは上記で上げたように、ある程度の妥協をしたほうが精神的に優しいのかもしれない。

  1. 一度 unknown にキャストしないと TypeScript ではエラーとなる

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?