✍️

Next.jsのチュートリアルコードをTypeScriptで書き換えてみた

2021/05/18に公開
2

始めに: 初心者が書いた記事のため、参考にはしないほうが良い

Next.jsでブログ作りたいというのがあり、どうせ作るならTypeScript使おうと思っていて、チュートリアルのブログ作成を書き換えてみようかなと思った。ちなみに私は、TypescriptもNext.jsも初心者、ReactをJavascriptで少し書いていたくらいの人間。

Next.jsのチュートリアルではブログの作成を行う。日本語訳されたものは以下のQittaを参照すればよい

大幅にリニューアルされた Next.js のチュートリアルをどこよりも早く全編和訳しました

変更前、変更後、変更の説明の順で書いていく。

正解

githubに正解のコードがあるというコメントをいただきました。
ありがとうございます。
ここに漂流した人は、こんな記事より、下のリポジトリを見た方が良いです。
vercel/next-learn-starter

コードと説明

まずTypescriptのinterfacesファイルの中のindex.tsの内容(next.js with typescriptのテンプレートを作成した時、すでにファイルが存在し、ここに書くべきだと思ってすべて書いたが、おそらくglobalしたいinterface or typeをここに記述するべきなのだろうと思う)

// interfaces/index.ts
import { AppProps } from "next/app"
export type postDataResult = {
  id:string
  content: string
  data: {title:string, date:string}
  isEmpty: boolean
  excerpt: string
  orig: string
}

export type fileNameId = {
  params:{
    id: string
  }
}

export type postData = {
  id:string
  contentHtml:string
  title:string
  date:string
}

export type Props = {
  props:{
    allPostsData:postDataResult
  }
}
type PageProps = {
  title: string
  logData: {
    screenName: string
  }
}
export type AppPageProps = Omit<AppProps<PageProps>, "pageProps"> & {
  pageProps: PageProps
}

export type staticPaths = {
  paths:fileNameId[]
  fallback:boolean
}
export type staticProps = {
  props:{
      postData:postData
  }
}

まず、Date.tsx or Date.jsの書き換え

// javascrpit版
// components/Date.js
import { parseISO, format } from 'date-fns'

export default function Date({ dateString }) {
  const date = parseISO(dateString)
  return <time dateTime={dateString}>{format(date, 'LLLL d, yyyy')}</time>
}
// Typescript版
// components/Date.tsx
import {parseISO, format} from 'date-fns'
import { NextPage } from 'next'
const Date:NextPage<{dateString:string}> = ({ dateString }:{dateString:string}) => {
    const date = parseISO(dateString)
    return <time dateTime={dateString}>{format(date, 'LLLL d, yyyy')}</time>
}

export default Date

DateのdateStringの引数をstringで型指定している。propsの型指定は{}で囲ってやって指定しないといけないっぽい(もしかするとtype xxx ={}の形でtypeを作ってやってそれを使うのが一般的なのかもしれない。もしくはほかの書き方があるかもしれない)

また、返り値はNextPage型を使う。NextPage<{dateString:string}>として明示的にstring型のdateStringを内部で使うことを指定してやる(のだと思う…)。
これ以外に、React.FCなどの書き方があるが、Nextなので今回はこれで書いた。
ただ、React.FCなども同じような意味合いっぽいので、どのようなときに、どっちを使えばよいのかはいまいちわかっていない(おそらくNextPageはRouter関係が処理されているとかそこらへんかもしれないしそうではないかもしれない)

ここら辺は、以下のサイトとかを一応参照した。
TypeScript で書く React コンポーネントを基礎から理解する
『実践TypeScript』第10章をNext.js 9で実習する
next.jsのTypeScript説明部分

次に、Layout.js to Layout.tsx

// javascript版
// component/Layout.js
import Head from 'next/head'
import Image from 'next/image'
import styles from './layout.module.css'
import utilStyles from '../styles/utils.module.css'
import Link from 'next/link'

const name = 'Your Name'
export const siteTitle = 'Next.js Sample Website'

export default function Layout({ children, home }) {
  return (
    <div className={styles.container}>
      <Head>
        <link rel="icon" href="/favicon.ico" />
        <meta
          name="description"
          content="Learn how to build a personal website using Next.js"
        />
        <meta
          property="og:image"
          content={`https://og-image.vercel.app/${encodeURI(
            siteTitle
          )}.png?theme=light&md=0&fontSize=75px&images=https%3A%2F%2Fassets.vercel.com%2Fimage%2Fupload%2Ffront%2Fassets%2Fdesign%2Fnextjs-black-logo.svg`}
        />
        <meta name="og:title" content={siteTitle} />
        <meta name="twitter:card" content="summary_large_image" />
      </Head>
      <header className={styles.header}>
        {home ? (
          <>
            <Image
              priority
              src="/images/profile.jpg"
              className={utilStyles.borderCircle}
              height={144}
              width={144}
              alt={name}
            />
            <h1 className={utilStyles.heading2Xl}>{name}</h1>
          </>
        ) : (
          <>
            <Link href="/">
              <a>
                <Image
                  priority
                  src="/images/profile.jpg"
                  className={utilStyles.borderCircle}
                  height={108}
                  width={108}
                  alt={name}
                />
              </a>
            </Link>
            <h2 className={utilStyles.headingLg}>
              <Link href="/">
                <a className={utilStyles.colorInherit}>{name}</a>
              </Link>
            </h2>
          </>
        )}
      </header>
      <main>{children}</main>
      {!home && (
        <div className={styles.backToHome}>
          <Link href="/">
            <a>Back to home</a>
          </Link>
        </div>
      )}
    </div>
  )
}
// Typescript版
// component/Layout.tsx
import Head from "next/head";
import Image from "next/image";
import styles from "./layout.module.css";
import utilStyles from "../styles/utils.module.css";
import Link from "next/link";
import {NextPage} from 'next'
type Props = {
  children?: React.ReactNode;
  home?: boolean;
};
const name: string = "Your Name";
export const siteTitle: string = "Next.js Sample Website";
const Layout:NextPage<Props> = ({ children, home}: Props) => {
  return (
    <div className={styles.container}>
      <Head>
        <link rel="icon" href="/favicon.ico" />
        <meta
          name="description"
          content="Learn how to build a personal website using Next.js"
        />
        <meta
          property="og:image"
          content={`https://og-image.vercel.app/${encodeURI(
            siteTitle
          )}.png?theme=light&md=0&fontSize=75px&images=https%3A%2F%2Fassets.vercel.com%2Fimage%2Fupload%2Ffront%2Fassets%2Fdesign%2Fnextjs-black-logo.svg`}
        />
        <meta name="og:title" content={siteTitle} />
        <meta name="twitter:card" content="summary_large_image" />
      </Head>
      <header className={styles.header}>
        {home ? (
          <>
            <Image
              priority
              src="/images/profile.jpg"
              className={utilStyles.borderCircle}
              height={144}
              width={144}
              alt={name}
            />
            <h1 className={utilStyles.heading2Xl}>{name}</h1>
          </>
        ) : (
          <>
            <Link href="/">
              <a>
                <Image
                  priority
                  src="/images/profile.jpg"
                  className={utilStyles.borderCircle}
                  height={108}
                  width={108}
                  alt={name}
                />
              </a>
            </Link>
            <h2 className={utilStyles.headingLg}>
              <Link href="/">
                <a className={utilStyles.colorInherit}>{name}</a>
              </Link>
            </h2>
          </>
        )}
      </header>
      <main>{children}</main>
      {!home && (
        <div className={styles.backToHome}>
          <Link href="/">
            <a>← Back to home</a>
          </Link>
        </div>
      )}
    </div>
  );
}

export default Layout;

引数はPropsで指定してやる。返り値も同様にNextPage<Props>でProps型のpropsを内部で使うことを指定。

次に、posts.js to posts.tsx

// javascript版
// lib/posts.js
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import remark from 'remark'
import html from 'remark-html'
const postsDirectory = path.join(process.cwd(), 'posts')

export function getSortedPostsData() {
  // Get file names under /posts
  const fileNames = fs.readdirSync(postsDirectory)
  const allPostsData = fileNames.map(fileName => {
    // Remove ".md" from file name to get id
    const id = fileName.replace(/\.md$/, '')

    // Read markdown file as string
    const fullPath = path.join(postsDirectory, fileName)
    const fileContents = fs.readFileSync(fullPath, 'utf8')

    // Use gray-matter to parse the post metadata section
    const matterResult = matter(fileContents)

    // Combine the data with the id
    return {
      id,
      ...matterResult.data
    }
  })
  // Sort posts by date
  return allPostsData.sort((a, b) => {
    if (a.date < b.date) {
      return 1
    } else {
      return -1
    }
  })
}

export function getAllPostIds() {
    const fileNames = fs.readdirSync(postsDirectory)
  
    // Returns an array that looks like this:
    // [
    //   {
    //     params: {
    //       id: 'ssg-ssr'
    //     }
    //   },
    //   {
    //     params: {
    //       id: 'pre-rendering'
    //     }
    //   }
    // ]
    return fileNames.map(fileName => {
      return {
        params: {
          id: fileName.replace(/\.md$/, '')
        }
      }
    })
  }

  export async function getPostData(id) {
    const fullPath = path.join(postsDirectory, `${id}.md`)
    const fileContents = fs.readFileSync(fullPath, 'utf8')
  
    // Use gray-matter to parse the post metadata section
    const matterResult = matter(fileContents)
  
    const processedContent = await remark()
    .use(html)
    .process(matterResult.content)
    const contentHtml = processedContent.toString()

    // Combine the data with the id and contentHtml
    return {
        id,
        contentHtml,
        ...matterResult.data
    }
  }
// Typescript版
// lib/posts.js
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import remark from 'remark'
import html from 'remark-html'
import {fileNameId, postData, postDataResult} from '../interfaces'
const postsDirectory= path.join(process.cwd(), 'posts')

export const getSortedPostsData = ():postDataResult => {
    const fileNames =  fs.readdirSync(postsDirectory)
    const allPostsData = fileNames.map(fileName => {
        const id = fileName.replace(/\.md$/, '')

        const fullPath = path.join(postsDirectory, fileName)
        const fileContents = fs.readFileSync(fullPath, 'utf8')

        const matterResult = matter(fileContents)
        return {
            id,
            ...matterResult
        }
    })
    return JSON.parse(JSON.stringify(allPostsData.sort((a, b) => {
        if (a.data.date < b.data.date) {
            return 1
        } else {
            return -1
        }
    })))
} 

export const getAllPostIds = (): Array<fileNameId> => {
    const fileNames = fs.readdirSync(postsDirectory)
    
    return fileNames.map((fileName):fileNameId => {
        return {
            params:{
                id: fileName.replace(/\.md$/, '')
            }
        }
    })
}

export const getPostData = async (id:string):Promise<postData> => {

    const fullPath = path.join(postsDirectory, `${id}.md`)
    const fileContents = fs.readFileSync(fullPath, 'utf8')

    const matterResult = matter(fileContents)
    const processedContent = await remark()
    .use(html)
    .process(matterResult.content)
    const contentHtml = processedContent.toString()
    const title = matterResult.data.title
    const date = matterResult.data.date
    return {
        id,
        contentHtml,
        title,
        date
    }
}

postDataResultはinterfaceの型。asyncなどの非同期処理ではPromise<>として書けとエラーが出たので、そう書いている。だから、もっと良い方法がある可能性がある。

getPostDataに関してはpostDataを返り値の型としているが、本来のチュートリアルではposts.jsのコードのようにmatterResultをスプレッドオペレータ(...)で展開する形でreturn値を作成している。しかし、grey-matterのmatter()の返り値は型が可変(返り値が複数あるから一意でない)であるため(なのかどうかはわからないがたぶんそう)、...matterResultで展開すると、型エラーが出る。そのため、明示的にtitle, dateで指定している。しかし、matterはmarkdownのyamlデータを読みとって、値を成型して返してくれるので、今回のように明示的に指定するのは良くない気がする。
おそらく、以下のサイトで述べているようにGrayMatterFileというinterfaceがあるので、これをmatterResultにうまく指定するのだと思う。
【TypeScript】gray-matterでsections

次に、[id].js to [id].tsx

// javascript版
// [id].js
import Layout from '../../components/layout'
import Head from 'next/head'
import { getAllPostIds, getPostData } from '../../lib/posts'
import utilStyles from '../../styles/utils.module.css'


export async function getStaticPaths() {
  const paths = getAllPostIds()
  return {
    paths,
    fallback: false
  }
}

export async function getStaticProps({ params }) {
    const postData = await getPostData(params.id)
    return {
      props: {
        postData
      }
    }
  }

  export default function Post({ postData }) {
    return (
        <Layout>
        <Head>
          <title>{postData.title}</title>
        </Head>
        <article>
          <h1 className={utilStyles.headingXl}>{postData.title}</h1>
          <div className={utilStyles.lightText}>
            <Date dateString={postData.date} />
          </div>
          <div dangerouslySetInnerHTML={{ __html: postData.contentHtml }} />
        </article>
      </Layout>
    )
  }
// Typescript版
// [id].tsx
import Layout from '../../components/Layout'
import Head from 'next/head'
import {getAllPostIds, getPostData} from '../../lib/posts'
import utilStyles from '../../styles/utils.module.css'
import { postData,staticPaths, staticProps } from '../../interfaces'
import Date from '../../components/Date'
import { NextPage } from 'next'

export const getStaticPaths = async ():Promise<staticPaths> => {
    const paths = getAllPostIds()
    return {
        paths,
        fallback: false
    }
}

export const getStaticProps = async ({params}:{params:{id:string}}):Promise<staticProps>=>{
    const postData = await getPostData(params.id)
    return {
        props: {
            postData
        }
    }
}

const Post:NextPage<{postData:postData}> = ({postData}:{postData:postData}) => {
    return (
    <Layout>
        <Head>
          <title>{postData.title}</title>
        </Head>
        <article>
          <h1 className={utilStyles.headingXl}>{postData.title}</h1>
          <div className={utilStyles.lightText}>
            <Date dateString={postData.date} />
          </div>
          <div dangerouslySetInnerHTML={{ __html: postData.contentHtml }} />
        </article>
    </Layout>
    )
}
export default Post

基本的にここまでくると同じように型を指定している。asyncの返り値にはPromise<返り値の型>、propsは{props名:propsの型}。

次に_app.js to _app.tsx

// javascript版
// _app.js
import '../styles/globals.css'

export default function App({ Component, pageProps }) {
  return <Component {...pageProps} />
}
// Typescript版
// _app.tsx
import { AppPropsType } from 'next/dist/next-server/lib/utils'
import '../styles/globals.css'
const App = ({ Component, pageProps }:AppPropsType) => {
  return <Component {...pageProps} />
}

export default  App

AppのAppPropsTypeはこう指定するのが普通らしい。

次に、index.js to index.tsx

// javascript版
// index.js
import Head from 'next/head'
import Layout, { siteTitle } from '../components/layout'
import utilStyles from '../styles/utils.module.css'
import { getSortedPostsData } from '../lib/posts'
import Link from 'next/link'
import Date from '../components/date'
export default function Home({ allPostsData }) {
  return (
    <Layout home>
      <Head>
        <title>{siteTitle}</title>
      </Head>
      <section className={utilStyles.headingMd}>
        <p>[Your Self Introduction]</p>
        <p>
          (This is a sample website - you’ll be building a site like this on{' '}
          <a href="https://nextjs.org/learn">our Next.js tutorial</a>.)
        </p>
      </section>
      <section className={`${utilStyles.headingMd} ${utilStyles.padding1px}`}>
        <h2 className={utilStyles.headingLg}>Blog</h2>
        <ul className={utilStyles.list}>
          {allPostsData.map(({ id, date, title }) => (
            <li className={utilStyles.listItem} key={id}>
              <Link href={`/posts/${id}`}>
                <a>{title}</a>
              </Link>
              <br />
              <small className={utilStyles.lightText}>
                <Date dateString={date} />
              </small>
            </li>
          ))}
        </ul>
      </section>
    </Layout>
  )
}

export async function getStaticProps() {
  const allPostsData = getSortedPostsData()
  return {
    props: {
      allPostsData
    }
  }
}
// Typescript
// index.tsx
import Head from 'next/head'
import Link from 'next/link'
import Layout, { siteTitle } from '../components/Layout'
import utilStyles from '../styles/utils.module.css'
import { getSortedPostsData } from '../lib/posts'
import Date from '../components/Date'
import {postDataResult, PropsAllPostsData} from '../interfaces'
import {NextPage} from 'next'
const Home:NextPage<{allPostsData: Array<postDataResult>}> = ({allPostsData}: {allPostsData: Array<postDataResult>}) => {
  return (
    <Layout home={true}>
      <Head>
        <title>{siteTitle}</title>
      </Head>
      <section className={`${utilStyles.headingMd} ${utilStyles.padding1px}`}>
        <h2 className={utilStyles.headingLg}>Blog</h2>
        <ul className={utilStyles.list}>
          {allPostsData.map(({ id, data}) => (
            <li className={utilStyles.listItem} key={id}>
              <Link href={`/posts/${id}`}>
                <a>{data.title}</a>
              </Link>
              <br />
              <small className={utilStyles.lightText}>
                <Date dateString={data.date} />
              </small>
            </li>
          ))}
        </ul>
      </section>
    </Layout>
  )
}

export const getStaticProps = ():PropsAllPostsData => {
  const allPostsData = getSortedPostsData()
  return {
    props: {
      allPostsData
    }
  }
}

export default Home

ここも同じようにNextPage<>を型として指定し、getStaticPropsには返り値のPropsAllPostsDataを指定している。

その他問題点

今回、いろんなところにgetStaticPropsやgetStaticPathなどが出てくるが、同名のインターフェースがnext内に存在する。今回、それらを型として用いようと思ったのだが、うまくいかなかった。おそらく、以下で述べられている理由などが原因だと思う。
Next.js の InferGetStaticPropsType が便利
上の記事によるとgetStaticPropsContentを引数にあてると書いてあるが当ててもうまくいかなかった…。そのため、結局getStaticProps型などはつかっていない。もしかすると、そっちを使う方が正当な方法の可能性がある。
ここら辺のProps周辺の型やCompornent周辺の型については勉強が必要な気がしている。
そして、ジェネリクスは絶対に理解して、挙動を考えながら書けるようにしないとなぁと...
【TypeScript】Generics(ジェネリックス)を理解する

あと、すべての変数に型指定が必要だと考えていたのだが、いろいろ調べるうえでそういうわけでもないことがわかり、今回は関数の引数と返り値以外はほとんど型の指定は行っていない。ここら辺も、いまいちどうやって指定してやればいいのかわからないというのが本音である。

まとめ

以上、ここのページ見に来るのはおそらくNext.jsとTypescript始めたての初心者が多いと思う(偏見)が、自分もそのたぐいでやってみた系の人なので参考にはしない方が良いと思う。

というか、ベストプラクティスのサイトとかあったら教えてくれ…

おまけ

vercelに上げてみたblog、問題なく上がっている。

Discussion

Kakeru HotakeKakeru Hotake

ありがとうございます。
知りませんでした。調べが足りませんでしたね。
見てみます