🐡

TypeScript 余剰プロパティって残るんだ!?

に公開

はじめに

こんにちは、 PortalKey の しゃり です。

弊社で開発中のアプリ PortalKey ではリモートワーク時に発生する「お互いの状況が分からない」を解消しようとしています。
その一環として、ユーザーが自分が通話できる状態なのか、アクティビティ(絵文字とコメント)を設定することで周囲にアピールする機能を作っています。

ユーザー操作によりアクティビティを更新するだけの単純な処理だったのですがTypeScriptの理解が浅く、バグを仕込みそうになったので備忘録の記事を書きます。

問題

単純なオブジェクトの比較で常にfalseを返すバグを仕込みそうになりました。

const isSameActivity = (a: Activity, b: Activity): boolean => {
  return deepEqual(a, b)
}

状況を以下で紹介していきます。

まず、アクティビティ変更に伴う処理は単純です。

  • ユーザーが自分のアクティビティを設定する
  • 前回設定したアクティビティと違う内容であればサーバーに送信する
    (同じアクティビティであればアプリ側で握りつぶす)

しかし、動作確認したところ同じアクティビティを設定してもサーバーへ送信していました。
その処理を超簡潔に書くとこんな感じ。

import deepEqual from "deep-equal"

const isSameActivity = (a: Activity, b: Activity): boolean => {
  return deepEqual(a, b)
}

const updateActivity = (newActivity : Activity): void => {
  // アプリ保管の、ユーザー自身が前回設定したアクティビティ(絵文字とコメント)を取得
  const currentActivity = getCurrentActivity()

  const isSame =  isSameActivity(currentActivity, newActivity)
  if (isSame) {
    return
  }

  // サーバーへ送信
  void api.activity.update({workspaceId, newActivity})
}

何が問題なのでしょうか?
コンパイルエラーはありません。動作上で問題があるのは、同じアクティビティを選んだ場合にもサーバーへ毎回送信していることだけでした。

原因

getCurrentActivityの戻り値がActivity interfaceの部分型であり余計なプロパティを持っていました。
そして、deepEqualはオブジェクトの全プロパティを検査します。

サバイバルTypeScript 部分型関係の解釈 より

export interface Activity {
  iconId: string
  message: string
}

// Activity の部分型を返していた
const getCurrentActivity = () => {
  // 実際の実装は動的な値を返す処理
  const activity = {
    userId: '1',
   iconId: '👻',
    message: '離席中'
  }

  return activity
}

解決方法

原因が分かれば対処は簡単でした。

// 対象プロパティを比較する自前実装
export const isSameActivity = (a: Activity, b: Activity): boolean => {
  return a.iconId === b.iconId && a.message === b.message
}

解説

TypeScriptは構造的型付けを採用しています。
代入や引数時に相手が求めるプロパティを満たしていれば構造的部分型となり、型エラーにならず普通に動いてくれるんですよね。初めて知った時はその便利さに小躍りしました。

また、この点で知らなかったことが2つありました。

  • interfaceは「そのプロパティを最低限持つということを保証しているだけ」
    「そのプロパティだけを全て持つ」という意味ではない。
  • オブジェクトの余剰プロパティはそのまま引き回されている
    つまり、関数に引数として渡された時点で型キャストされるなんてことはない。

以上のことからdeepEqualに異なるプロパティを持つオブジェクトを比較させていたことになり、必ずfalseを返す処理になっていました。

const updateActivity = (newActivity : Activity) => {
  console.log(newActivity) // {iconId: '👻', message: '離席中'}
    
  const currentActivity = getCurrentActivity()
  console.log(currentActivity) // {userId: '1', iconId: '👻', message: '離席中' }
  
  // userIdにアクセスできないためコンパイルエラーになる
  // console.log(currentActivity.userId)
  
  const isSame = isSameActivity(currentActivity, newActivity)
  console.log(isSame)  // false
  if (isSame) {
    return
  }
  
  void api.activity.update({workspaceId, newActivity})
}

useStateでは?

useStateに部分型を入れた場合も余剰プロパティは残っていました。
(ReactとTypescriptは独立してるので当然ですね)

検証

実際に検証してみました。
(UIに凝ったら検証部分のコードが分かりづらくなってしまい少し反省しています。)

まとめ

TSは…

  • 構造的型付けである
    interfaceやtypeは、「そのプロパティしか持ってないぜ!」ではなく「そのプロパティを最低限持ってる型だぜ!」を保証している。
  • 部分型つまり「上位型の要素をすべて持っている型」であれば引数として渡したり変数に代入できる
  • 部分型を代入した場合、余剰プロパティは保持される

Reactは…

  • useStateに部分型を渡した場合、余剰プロパティは保持される
PortalKey Tech Blog

Discussion