React・TypeScriptで書く

データ・Context・フックに型を通す

アプリの中心にあるデータの型を1か所にまとめ、Context・カスタムフック・詳細ページまで通します。fetchの戻り値がanyになる危うさ、findがundefinedを返す話、そして型が設計のほころびを見せてくれる場面も扱います。

前回は部品に型を付けました。今回はアプリの中心にあるデータに型を通します。

中心の型は1か所に置く

Bird型は、カード・フォーム・Context・詳細ページとあちこちで使います。こういう型は専用のファイルに置きます。

export type Bird = {
  // JSONのidは数値、フォームで足した鳥は文字列のUUID
  id: number | string
  name: string
  emoji: string
  area: string
  rare: number
  memo: string
}

fetchの戻り値はany——ここで検査が切れる

Contextのfetchに型を通します。

const [birds, setBirds] = useState<Bird[]>([])

useEffect(() => {
  fetch('/birds.json')
    .then((response) => response.json())
    .then((data) => {
      setBirds(data)      // data は any
      setIsLoading(false)
    })
}, [])

setBirdsBird[]しか受け取らないのに、これはエラーになりませんresponse.json()の戻り値がanyだからです。

anyは「検査しない」という意味なので、そこで型の守りが切れます。JSONの中身がBirdの形をしている保証は、TypeScriptのどこにもありません。

対処は2段階あります。

// ①「こう扱う」と宣言する(実務でいちばん多い)
.then((data) => setBirds(data as Bird[]))

// ②本当に確かめる(外部APIならこちら)
.then((data) => setBirds(parseBirds(data)))   // 自分で中身を検証する関数

Contextの型——nullを型で表す

createContext(null)のままだと、前回のエラーにあったとおり「null型に値は入れられない」と怒られます。

type BirdsContextValue = {
  birds: Bird[]
  isLoading: boolean
  addBird: (bird: Bird) => void
  removeBird: (id: Bird['id']) => void
}

const BirdsContext = createContext<BirdsContextValue | null>(null)

| nullが肝心です。Providerの外で使われたときはnullが返る——その事実を型で表しています。

そして読み出し口でnullを弾きます。

export function useBirds() {
  const value = useContext(BirdsContext)
  if (!value) {
    throw new Error('useBirds は BirdsProvider の中で使ってください')
  }
  return value   // ここから先は BirdsContextValue(nullではない)
}

カスタムフックの戻り値は推論に任せる

useFavoritesには戻り値の型を書きません

export function useFavorites() {
  const [favoriteIds, setFavoriteIds] = useState<Bird['id'][]>([])
  const toggleFavorite = useCallback((id: Bird['id']) => { … }, [])

  return { favoriteIds, toggleFavorite }   // 型は自動で決まる
}

中でuseState<Bird['id'][]>と書いてあるので、戻り値の形はTypeScriptが計算できます。ここに手で型を書くと、中身を変えるたびに2か所直すことになります。

「入口(引数・state)に型を書き、出口は推論に任せる」——これが書きすぎないコツです。

findundefinedを返す

詳細ページで、こういうエラーが出ます。

error TS18048: 'bird' is possibly 'undefined'.
const bird = birds.find((item) => String(item.id) === id)

find見つからなければundefinedを返します。URLに存在しないidが来ることは実際にあるので、TypeScriptの指摘は正しいのです。

前回書いた早期returnがそのまま効きます。

if (!bird) {
  return <p>その鳥は見つかりませんでした。</p>
}

// ここから下では bird は Bird(undefined ではない)

通し終えたら、検査が緑になる

ここまででtscが通ります。

$ npx tsc -b
$ echo $?
0

何も出力されないのが成功です。そしてnpm run buildも、型検査 → ビルドの順に走って通ります。

公式ドキュメントも見てみる

ja.react.dev外部サイトuseContext – React公式のuseContextリファレンス。TypeScriptと組み合わせるときの型の付け方(createContextのジェネリクス)にも触れています

このレッスンのまとめ

  1. 中心のデータ型はsrc/types.tsに1か所。部品専用の型はその場に置いたままでよい
  2. 型は設計のほころびを見せる——id: number | stringに気づけたのがその例
  3. response.json()any。そこで検査が切れる
  4. asは保証ではなく宣言。外部APIなら検証ライブラリで本当に確かめる
  5. ContextはcreateContext<値 | null>(null)、読み出しフックでnullを弾く
  6. 入口に型を書き、出口(戻り値)は推論に任せる
  7. findundefinedを返す。早期returnで絞りこむ。ただしfunctionの中までは届かない

次回は最終回。型と長く付き合うコツ——anyunknown、状態をユニオンで表す書き方、型エラーの読み方です。

やってみよう:データとContextに型を通す

残っているエラーを全部消します。

  1. src/types.tsBird型を(前回作っていなければ)作る
  2. BirdsContext.tsxBirdsContextValue型を作り、createContext<BirdsContextValue | null>(null)にする
  3. useState([])useState<Bird[]>([])に、addBirdremoveBirdの引数に型を付ける
  4. BirdsProviderchildrenReactNodeを付ける
  5. useFavorites.tsuseStatetoggleFavoriteに型を付ける(戻り値には書かない
  6. 詳細ページのhandleRemoveconstの関数に直す
  7. npx tsc -b何も出力しなくなるまで進める
import { createContext, useContext, useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import type { Bird } from '../types.ts'

type BirdsContextValue = {
  birds: Bird[]
  isLoading: boolean
  addBird: (bird: Bird) => void
  removeBird: (id: Bird['id']) => void
}

const BirdsContext = createContext<BirdsContextValue | null>(null)

export function BirdsProvider({ children }: { children: ReactNode }) {
  const [birds, setBirds] = useState<Bird[]>([])

}

最後にnpm run buildを実行して、型検査を通ってからビルドされることを確認してください。ここまで来れば、このプロジェクトは「型のあるReactアプリ」になっています。

よくある質問

データの型はどこに置けばいいですか?
アプリ全体で使う中心的な型は、src/types.tsのような専用ファイルに置くのが分かりやすいです。1つの部品でしか使わない型は、その部品のファイルに置いたままで構いません。
fetchで取ってきたデータの型はどうなりますか?
response.json()の戻り値はanyです。つまり型の検査がそこで切れます。asで型を宣言するのが一般的ですが、それは中身の保証ではなく「こう扱う」という宣言にすぎません。
findで取り出した値にエラーが出ます
findは見つからなければundefinedを返すため、TypeScriptはundefinedの可能性を指摘します。早期returnで存在を確認してから使えば消えます。
createContextの型はどう書きますか?
createContext<値の型 | null>(null)と書くのが定番です。Providerの外で使われたときにnullが返ることを型で表現でき、読み出し用のフックでnullを弾けば、使う側では常に値がある前提で書けます。