🤔

型安全にオブジェクトの値をmapしたい(Object.entriesとObject.fromEntriesで)

2023/02/11に公開約3,700字

Object.entriesObject.fromEntriesを使ってオブジェクトの値を変換できます。

例えばこんな感じのやつです。

const mapObjectValue = (data, mapfn) => {
    const keyValueList = Object.entries(data)
    const mappedList = keyValueList.map(([key,value]) => [
            key,
            typeof value === "object" && value != null && value.constructor.name === "Object"
                ? mapObjectValue(value, mapfn) 
                : mapfn(value)
        ])
    return Object.fromEntries(mappedList)
}

再帰関数でネストしたオブジェクトに対応しています。
クラスを展開してもしょうがないので、クラス(constructor.name"Object"以外のオブジェクト)にはmapFnの処理を適用させています。
Dateを展開しても空のオブジェクトになるだけですし。

出力サンプル

const data = {
    num: 1,
    str: "one",
    date: new Date(),
    obj: {
        num: 2,
        str: "two",
        obj: {
            num: 3,
            str: "three",
        }
    }
};
// オブジェクトの値をすべてstringに変換する
console.log(mapObjectValue(data, value => String(value)))
/**
 * output
  {
    "num": "1",
    "str": "one",
    "date": "Sat Feb 11 2023 23:33:41 GMT+0900 (日本標準時)",
    "obj": {
      "num": "2",
      "str": "two",
      "obj": {
        "num": "3",
        "str": "three"
      }
    }
  } 
 */

TypeScript化する

エラーが出ない程度のパターン

申し訳程度に型を設定すると返り値で型情報が失われてしまいます。

const mapObjectValue = <T extends Object, R>(data: T, mapfn: (value: any) => R) => {
    const keyValueList = Object.entries(data)
    const mappedList = keyValueList.map(([key,value]) => [
            key,
            typeof value === "object" && value != null 
                ? mapObjectValue(value, mapfn)
                : mapfn(value)
        ]) as [keyof T, any][]
    return Object.fromEntries(mappedList)
}

const ret = mapObjectValue(data, (value) => String(value))
/**
 * 推論された型 
  const ret: {
    [k: string]: any;
  }
 */

返り値の型を指定するパターン

返り値の型を再帰で定義してみます。

// Tの値をすべてR型に変更する型
type ValueMapped<T,R> = { [K in keyof T]: T[K] extends object ? ValueMapped<T[K], R>: R}

const mapObjectValue = <T extends object,R> (data: T, mapfn: (_: any) => R): ValueMapped<T,R> => {
    const keyValueList = Object.entries(data)
    const mappedList = keyValueList.map(([key,value]) => [
            key,
            typeof value === "object" && value != null && value.constructor.name === "Object"
                ? mapObjectValue(value, mapfn) 
                : mapfn(value)
        ])
    return Object.fromEntries(mappedList)
}
const ret = mapObjectValue(data, (value) => String(value))
/**
 * 推論された型
  {
      num: string,
      str: string,
      date: object, ☆ 実態はstring
      obj: {
          num: string,
          str: string,
          obj: {
              num: string,
              str: string
          }
      }
  }
 */

なんとなくうまくいってそうに見えますが失敗しています。

問題点

  • classもobjectとして扱われている
    • TypeScriptではclassとMap(keyとvalueの集合)を区別できない
    • 自分の書くコードはclassを作らない手段を取れますが、クラスベースで作られたライブラリやDateRexExpなど組み込みclassがある
  • mapFnの引数の型がanyになっているので必要のない型チェックをする必要がある
    • ネストしたオブジェクトの値の型を一つにまとめたUnionTypesを作ることができれば可能だがTypeScriptでは不可能(なハズ)

まとめ

というわけでできると思ってやってみたらできなかった話でした。
「実はできるよ」と誰かがコメントしてくれることを祈っています。

余談ですが4.9で追加されたsatisfiesで型チェックができて便利ですね。

type ExpectedType = {
    num: string,
    str: string,
    date: string,
    obj: {
        num: string,
        str: string,
        obj: {
            num: string,
            str: string
        }
    }
}

const ret = mapObjectValue(data, (value) => String(value)) satisfies ExpectedType // 型不一致でエラーが出る

Discussion

ログインするとコメントできます