AppComponentの処理を型安全に行う

この記事は [Next.js Advent Calendar 2019](https://qiita.com/advent-calendar/2019/next-js) の21日目の記事です。

Next.js はフレームワークとして必要最低限の機構のみ提供しているため、運用上必要になる機構は自作する必要があります。例えば全ページで「メタデータの注入」が必要になったとき、それを制約する機構は備わっていません。本稿では、middleware とも呼べる AppComponent でこの受領するメタデータを型で守り、処理を安全に実行するTIPSを紹介します。

Next.js のバージョンは次のものです。

```package.json
"next": "^9.1.6"
```

# 求めるコードベースの確認

### Title の指定が簡略化できる

ページタイトルは必須になりますので、getInitialProps で文字列を返すだけでタイトルが指定出来れば便利です。Page毎に`"next/head"`を import せずにすみます。

```pages/index.tsx
import Component from "../components/index"

const PageComponent = () => <Component />

PageComponent.getInitialProps = async () => {
  // titleの設定はこれだけだと幸せ
  return { title: "TOP" }
}
```

`_app.tsx`が全ページの高階コンポーネントになるので、次の様な AppComponent にしてしまえば実現できます。この様に各pageではメタデータを返すのみとし、取り扱いを一元管理できれば、柔軟な拡張を施すことができます。

```pages/_app.tsx
import Head from "next/head"

const AppComponent = ({ Component, pageProps }) => (
  <>
    <Head>
      <title>{pageProps.title}</title>
    </Head>
    <Component {...pageProps} />
  </>
)
```

### 全ページに非同期処理を挟める

ページタイトル以外にも、全ページで共通の非同期処理を行う必要がある場合。これも同じく実現する場所は`_app.tsx`です。次のように、全ページでログの送信を試みます。

```pages/_app.tsx
AppComponent.getInitialProps = async (appContext) => {
  const appProps = await App.getInitialProps(appContext)
  // 全ページにメタデータを要求し、
  // ロギングなどの middleware非同期処理を挟みたい。
  const { logData } = appProps.pageProps
  // ロギングAPIなどをここで叩く
  await log(logData)
  return { ...appProps }
}
```

# 課題の確認

このままでは、各pageコンポーネントの getInitialProps 返り値に不整合があった場合、事故に繋がります。ロギングAPI の payload が、将来的に変更になることも想定できます。これを型定義で要求する様に変更していきます。


# 型制約を設け全ページで要求する

必要最低限のプロパティを表す型定義です。全ページの getInitialProps 返り値にこれを要求します。

```typescript
type PageProps = {
  title: string
  logData: {
    screenName: string
  }
}
```

Next.js にバンドルされている`NextPage`型を利用し、Genericsで注入します。この型付与が行われていれば、戻り値が期待値を満たしていないページがある場合、コンパイルエラーを得ることができます。

```pages/index.tsx
import { NextPage } from "next"
import { PageProps } from "../types"
import Component from "../components/index"

const PageComponent: NextPage<PageProps> = () => (
  <Component />
)

PageComponent.getInitialProps = async () => {
  // 戻り値が期待値を満たしていない場合、コンパイルエラーを得られる
  return {
    title: "TOP",
    logData: { screenName: 'TOP' }
  }
}
```


# 要求を一限管理する

全ページに求める記述ですので、冗長な import を簡略化したいです。次のとおり変更していきます。

```diff
- import { NextPage } from "next"
- import { PageProps } from "../types"
+ import { PageFC } from "next"
```
```diff
- const PageComponent: NextPage<PageProps> = () => (
+ const PageComponent: PageFC = () => (
```

`PageFC`型は、`NextPage`型を拡張した独自定義型です。必要な拡張だけを施し、Generics で各ページ固有のプロパティ注入ができる振る舞いはそのままとします。

```typescript
type PageFC<P = {}, IP = P & PageProps> = NextPage<P, IP>
```

ここまでの型を、Ambient Module 宣言します。これで、パッケージから提供されている型の様に扱うことが出来ます。

```types/index.ts
import "next"
declare module "next" {
  // 全ページ getInitialProps 戻り値に要求する、必須プロパティ
  export type PageProps = {
    title: string
    logData: {
      screenName: string
    }
  }
  // PageComponent に適用する型
  export type PageFC<P = {}, IP = P & PageProps> = NextPage<P, IP>
}
```

# _error.tsx に適用してみる

試しに`_error.tsx`に適用してみましょう。`ErrorProps`型は Next.js から提供されている型です。

```pages/_error.tsx
import React from "react"
import { PageFC } from "next"
import { ErrorProps } from "next/error"

const ErrorComponent: PageFC<ErrorProps> = ({ statusCode }) => (
  <>
    {statusCode
      ? `An error ${statusCode} occurred on server`
      : "An error occurred on client"}
  </>
)

ErrorComponent.getInitialProps = async ({ res, err }) => {
  const statusCode = res ? res.statusCode : err ? err.statusCode : 404
  return {
    statusCode,
    title: `Error | ${statusCode}`,
    logData: { screenName: "Error" }
  }
}

export default ErrorComponent
```


# AppComponent の引数型注釈

これだけでは、AppComponent でタイトル注入を行う際に問題が発生します。Next.js から提供されている`AppProps`型は、肝心な`pageProps`に対し`any`が付与されてしまっています。

```pages/_app.tsx
import Head from "next/head"
import { AppProps } from "next/app"

const AppComponent = ({ Component, pageProps }: AppProps) => (
  <>
    <Head>
      {/** var pageProps: any */ }
      <title>{pageProps.title}</title>
    </Head>
    <Component {...pageProps} />
  </>
)
```

この問題は、`any`が付与された`pageProps`を`Omit`で一度削り、`PageProps`型を再付与することで、解決出来ます。独自定義型`AppPageProps`を新たに追加します。

```typescript
export type AppPageProps = Omit<AppProps<PageProps>, "pageProps"> & {
  pageProps: PageProps
}
```
次の様に書くこともできます。`Override`型を用意しておけば、本家の型定義に`any`が付与されて困った時に便利です。（元の型を書き換えることはできません）

```typescript
type Override<T extends U, U> = Omit<T, keyof U> & U
export type AppPageProps = Override<
  AppProps<PageProps>,
  { pageProps: PageProps }
>
```


もし運用中に何か必須項目を増やす必要がでた場合、`PageProps`型に手を入れてしまえば、あとはコンパイラが修正すべき箇所を教えてくれます。サンプルコードは以下に起きましたので、覗いてみてもらえると嬉しいです。

https://github.com/takefumi-yoshii/nextjs-typescript-sandbox