🚀

【TypeScript】型定義をちょっと頑張って型チェックの手間を減らそう

2021/04/03に公開

型、書いてる?

TypeScript ではざっくり以下の場合には型を書かなければならない。

  1. APIのレスポンスなど、ソースコードの外から与えられる情報に型を与えるとき
  2. 引数や戻り値が anyunknwon で定義されている、やる気のないライブラリの戻り値に型情報を与えるとき
  3. 関数を定義するとき

このとき、面倒だからと言って愚直な型定義をすると、if 文で型ガードを書きまくる羽目になって、むしろ手間が増えるなんてことが起こる。
機械的にやらせたいところだけど、現状人間が書くしか無いのでとにかくめんどくさい。

でもそれ、もしかしたら型定義のほうを工夫すると一気に楽になるかも?ということで、普段使っている手法を3つほど挙げることにする。
黒魔術は使わず直感的な書き方のものだけを挙げたので、あまり気張らずに読める内容になっていたら幸いです。

共通項がないオブジェクトの型を切り替える

「共通項がない」とは、以下のように A と B に共通のプロパティがまったくない場合である。

type A = {
 x: number
}

type B = {
 y: number
}

これは、APIとやり取りをするときに状態によってレスポンスの中身が全く違うような場合によく遭遇する。
そのようなAPIのレスポンスの型定義を考える。

例えば
「ログイン前の場合は session というプロパティがあり、ログイン後の場合は token というプロパティと user というプロパティがある」
というパターン。

これを以下のように定義してはいないだろうか。

type UserInfo = {
  user?: {...}
  session?: string
  token?: string
}

はい、見事に全てが nullable です。このままでは無限に型ガードのif文を書く羽目になります、やめましょう。
以下のようにして、きちんと場合ごとの型を定義してやるのがベターである。

type NoLoginUserInfo = {
  session: string
}

type LoginedUserInfo = {
  user: {...},
  token: string
}

export type UserInfo = NoLoginUserInfo | LoginedUserInfo

こうすると、以下のように片方にしか存在しないキーの存在を in で判定するだけできちんとそれぞれの型に推論される。
isLoginedUser(user: UserInfo): user is LoginedUserInfo のような判定用関数を書く必要はない。

function checkLogin(userInfo: UserInfo) {
  if ("token" in userInfo) {
    // userInfo が LoginedUserInfo として推論される
  } else {
    // userInfo が NoLoginUserInfo として推論される
  }
}

この程度の型チェックあればおそらく素のJavaScriptでもやるはずである。
TypeScriptの都合だけでif文を書くのはどう考えてもダサいので、 このように型定義を頑張ってAltJSとしての格を見せてやるのがいい。

型を特定する手がかりがあるオブジェクトの場合

先の例とは違って、今度はオブジェクト内に種類を特定する手がかりとなるプロパティがある場合である。

例えば、 社員の情報を表す型を考える。

type ContractType = "fulltime" | "parttime"

type Employee = {
  contractType: ContractType
  department: string
  salary: number
  bonus?: number
  period?: number
}

これは、contractType が fulltime 、つまり正社員の場合は無期雇用なので period が無く、bonus がある。
また、 parttime 、つまりパートタイム社員の場合は有期雇用なので period があり、 bonus が無い(世知辛い)。

しかし、この型定義だと、以下のように if文でチェックするときに二度手間が発生する。

function calcSalary(employee: Employee) {
  if (employee.contractType === "fulltime") {
    // fulltime なので bonus が仕様上あるはずだが、TypeScriptがそのことを知らないためエラーになる
   return employee.salary + employee.bonus
    // parttime なので period があるはずだが以下略
  } else if (employee.period < Date.now()) {
    return employee.salary
  } else {
    return 0 // 悲しい
  }
}

これは、型定義を以下のようにすることで解決出来る。

type FulltimeEmployee = {
  contractType: "fulltime"
  department: string
  salary: number
  bonus: number
}

type ParttimeEmployee = {
  contractType: "parttime"
  department: string
  salary: number
  period: number
}

type Employee = FulltimeEmployee | ParttimeEmployee

このようにすることで、以下のような型推論結果になる。

function calcSalary(employee: Employee) {
  if (employee.contractType === "fulltime") {
    // ここで employee が FulltimeEmployee  に推論されるためエラーにならない
   return employee.salary + employee.bonus
    // ここで employee が ParttimeEmployee  に推論されるためエラーにならない
  } else if (employee.period < Date.now()) {
    return employee.salary
  } else {
    return 0 // 悲しさは変わらない
  }
}

contractType の型定義が ContractType でなくなった点は少し気になるかもしれないが、そこはユニットテストをきちんと書けば間違いは潰せるはずである。
型定義ですべてを頑張る必要はない、ということだ。

ちなみに、Reduxでこの仕様を上手く使うととても快適に Reducer の定義が出来る。

const COUNT_UP = "COUNT_UP" as const
const CountUp = (increases: number) => ({
  type: COUNT_UP,
  payload: {
    increases
  }
})

const COUNT_DOWN = "COUNT_DOWN" as const
const CountUp = (decreases: number) => ({
  type: COUNT_DOWN,
  payload: {
    decreases
  }
})

type Actions = ReturnType<
  | typeof CountUp
  | typeof CountDown
>
  
function reducer(state: State, action: Actions): State {
  switch(action.type) {
    case COUNT_UP: {
      return {
        ...state,
	// action.type を手がかりに、 payload の型が推論されている
        count: state.count + action.payload.increases
      };
    }
    case COUNT_DOWN: {
      return {
        ...state,
        count: state.count - action.payload.decreases
      };
    }
  }
  return state
}  

nullable なプロパティと上手く付き合う

Optional Chaining という機能がある。
nullable なオブジェクトに対して hoge?.fuga?.piyo とアクセス出来るやつである。
これは使うのが意外と難しい機能で、そのまま使うと結局 undefined やんけと怒られる。

type Foo = {
  hoge?: {
    x: number
  }
  fuga?: {
    y: number
  }
}

function doSomething(foo: Foo) {
  return foo.hoge?.x + foo.fuga?.y //  各項が number | undefined になるので足し算が出来ない
}

どう使うと上手いのか、のような話は本題とはちょっとズレるので、ここではこの Optional Chaining が存在することを前提とした型定義を検討してみたい。

例えば、以下のように敢えて undefined との Union 型にしてみる。
React の Component 定義で使うと便利さがわかりやすい。

type Props = {
  width: number | undefined
  height: number | undefined
}

export const Rectangle = (props: Props) => {
  const { width = 100, height = 100 } = props

  return // 以下略...
}

width?: numberwidth: number | undefined の違いは、そのプロパティが存在しないことを許容するかしないかである。

上記の Component は、呼び出し側で widthheight のプロパティを指定することを要求される。

<Rectangle /> {/* width と height がないというエラーになる */}

これは、以下のようにフォームの入力がないときでもデフォルト値を使って動いて欲しいようなときに便利。

const App = () => {
  const [size, setSize] = useState<{width: number, height: number} | null>(null);
  
  return ( 
  <>
    <input onChange={e => {
      const values = e.target.value.split(",")
      setSize({ width: parseInt(values[0]), height: parseInt(values[1]) })
    }} />
    <Rectangle width={size?.width} height={size?.height} />
  </>);
};

まあ、正直な話をいうと

<Rectangle width={size?.width ?? 100} height={size?.height ?? 100} />

みたいに、 null合体演算子 ?? と組み合わせて使うほうが分かりやすいし、記述も簡潔ではある。
(上記の例では、parseInt が NaN を返す可能性があるが、 NaN は ?? を使っても普通に左辺の値が返されるので、安全性はどちらを使っても変わらない)

終わりに

今回は覚えやすく直感的に書けそうなイディオムを紹介してみました。

たぶん ConditionalTypes みたいな黒魔術を使えばもっと複雑な事もできるけど、ぶっちゃけそこをいくら頑張ったところで JavaScriptに変換される部分を書かないとアプリケーションは一生完成しない ので、あまりやりすぎるとコスパが良くないかなと個人的には思ってます。
Template Literals は色々応用出来そうなので、思いついたらまた記事を書いてみます。

それでは快適な TypeScript ライフを送りましょう。

Discussion