Next.jsをRosettaで多言語対応(i18n)して型に気を付けたメモ

## 概要
[前回はとりあえずコンパイラを通すために適当に型をつけてしまっていた](https://qiita.com/hibohiboo/items/27f03350feafd32b6b91)。
今回、型をきちんとつけてやることで、定義ファイルにないものを使うとエラーが起きるように修正した。

[この時点のソース](https://github.com/hibohiboo/create-now/tree/8cf261adbdff2c680b4a4b7bd64e59370cb3697a/)

## ソース

* `typeof JA`で、日本語の定義ファイルをベースとして型つけを行う
  * 他の言語に追加忘れたときの検知可能
* `keyof Dict`で、定義ファイルのプロパティを引数にとるようにする
  * タイポの検知
  * 未定義のものは使用できないようにする
* `[defaultLanguage, 'en'] as const`
  * const assertion.TypeScript 3.4^.
  * 配列の要素の型をstring[]ではなくreadonly["ja", "en"]とリテラル型にできる
* `typeof languages[number]`
  *  リテラルの配列をUnion Typeに変換. 
  *  T[number]は配列Tに対してnumber型のプロパティ名でアクセスできるプロパティの型 = 配列Tの要素の型
* `{ [K in Language]: string }`
  *  Mapped Type
  *  オブジェクトはLanguageで定義されたプロパティを持ち、その型はstringである

```diff:src/lib/i18n.tsx
import { createContext, useState, useRef, useEffect } from 'react'
import rosetta from 'rosetta'
// import rosetta from 'rosetta/debug';

// 日本語をベース
+ import JA from '~/locales/ja.json'
+ type Dict = typeof JA
+ export const defaultLanguage = 'ja'
+ export const languages = [defaultLanguage, 'en'] as const
+ type Language = typeof languages[number]
+ export const contentLanguageMap: { [K in Language]: string } = {
+   ja: 'ja-JP',
+   en: 'en-US',
+ }

export const I18nContext = createContext(null)

// default language
i18n.locale(defaultLanguage)


+ export type I18n = {
+   activeLocale: Language
+   t: (...args: [keyof Dict | (string | number)[], any?, Language?]) => string
+   locale: (l: Language, dict: Dict) => void
+ }


+ const I18n: React.FC<{
+   locale: Language
+   lngDict: Dict
+ }> = ({ children, locale, lngDict }) => {
  const [activeDict, setActiveDict] = useState(() => lngDict)
  const activeLocaleRef = useRef(locale || defaultLanguage)
  const [, setTick] = useState(0)
  const firstRender = useRef(true)

  // for initial SSR render
  if (locale && firstRender.current === true) {
    firstRender.current = false
    i18n.locale(locale)
    i18n.set(locale, activeDict)
  }

  useEffect(() => {
    if (locale) {
      i18n.locale(locale)
      i18n.set(locale, activeDict)
      activeLocaleRef.current = locale
      // force rerender
      setTick((tick) => tick + 1)
    }
  }, [locale, activeDict])


+   const i18nWrapper: I18n = {
    activeLocale: activeLocaleRef.current,
    t: (...args) => i18n.t(...args),
    locale: (l, dict) => {
      i18n.locale(l)
      activeLocaleRef.current = l
      if (dict) {
        i18n.set(l, dict)
        setActiveDict(dict)
      } else {
        setTick((tick) => tick + 1)
      }
    },
  }

  return (
    <I18nContext.Provider value={i18nWrapper}>{children}</I18nContext.Provider>
  )
}
export default I18n
```

```tsx:hooks/use-i18n.tsx
import { useContext } from 'react'
import { I18nContext, I18n } from '~/lib/i18n'

export default function useI18n() {
  const i18n: I18n = useContext(I18nContext)
  return i18n
}
```

* フラットなjsonに変更
* 単語の区切りは`_`で表現
  * キーに`.`を使うと、rosettaがオブジェクトのプロパティと解釈してしまう
  * `.`以外なら何でもいい

```json:location/ja.json
{
  "lostrpg_index_title": "LOSTRPG サポートページ",
  "lostrpg_index_campList": "キャンプ一覧",
  "lostrpg_camps_list_title": "LOSTRPG キャンプ一覧",
  "lostrpg_camps_list_campName": "キャンプ名",
  "lostrpg_camps_list_campList": "キャンプ一覧",
  "common_back": "戻る",
  "common_create": "新規作成"
}
```

* 利用例

```diff
    <Container>
      <Head>
        <meta
          httpEquiv="content-language"
          content={contentLanguageMap[i18n.activeLocale]}
        />
-        <title>{i18n.t('lostrpg.index.title')}</title>
+        <title>{i18n.t('lostrpg_index_title')}</title>
      </Head>
```


## 参考
[rosetta](https://github.com/lukeed/rosetta)
[TypeScript: 配列の値をString Literal Typesとして使う](https://dackdive.hateblo.jp/entry/2019/08/13/090000)
[TypeScriptの型入門](https://qiita.com/uhyo/items/e2fdef2d3236b9bfe74a#mapped-types)
[純 typescript](https://nju33.com/typescript)
[next.js rosetta example](https://github.com/zeit/next.js/tree/canary/examples/with-i18n-rosetta)