🤔

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

2023/02/12に公開1

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

nap5nap5

達成したいことがやや不透明ですが、汲み取って少しやってみました。

定義側

import { Simplify } from "type-fest";
import { flatten, unflatten } from "flat";
import { walker, Node } from "obj-walker";
import { isEmpty } from "radash";

type ValueMapped<T, R> = Simplify<{
  [K in keyof T]: T[K] extends Date
  ? R : T[K] extends RegExp
  ? R
  : T[K] extends object
  ? ValueMapped<T[K], R>
  : R;
}>;

export const traverse =
  <T, R>(input: T) =>
    (fn: (d: Node) => Record<string, unknown>) =>
      (output: Record<string, unknown>) => {
        walker(
          flatten(input),
          (d: Node) => {
            if (isEmpty(d.key)) return;
            return Object.assign(output, fn(d));
          },
          {
            postOrder: true,
            jsonCompat: true,
          }
        );
        return unflatten(output) as ValueMapped<T, R>;
      };


export const factor = (fn: (d: Node) => unknown) => (d: Node) => {
  const neatKey = d.key!;
  return { [neatKey]: fn(d) };
};

使用側

import { test, expect } from "vitest";
import { factor, traverse } from ".";
import { Node } from "obj-walker";
import { capitalize, isNumber, isObject, isString } from "radash";
import { isDate } from "util/types";
import { logFormat } from "./utils";

const data = {
  num: 1,
  str: "hello",
  date: new Date(),
  isManager: false,
  regexp: new RegExp(""),
  obj: {
    num: 2,
    str: "world",
    isManager: true,
    obj: {
      num: 3,
      str: "!",
    },
  },
};

type Demo = typeof data;

test("xxx", () => {
  const toXXX = (d: Node) => {
    if (isNumber(d.val)) return d.val * 1_000;
    if (isString(d.val)) return capitalize(d.val);
    if (isDate(d.val)) return logFormat(d.val);
    if (isObject(d.val)) return d.val
    return d.val as boolean;
  };
  const result = traverse<Demo, ReturnType<typeof toXXX>>(data)(factor(toXXX))(
    {}
  );
  expect(result).toStrictEqual({
    num: 1000,
    str: 'Hello',
    regexp: /(?:)/,
    date: '2023-09-02T00:40:54+09:00',
    isManager: false,
    obj: {
      num: 2000,
      str: 'World',
      isManager: true,
      obj: { num: 3000, str: '!' }
    }
  });
});

test("toString", () => {
  const toString = (d: Node) => String(d.val);
  const result = traverse<Demo, ReturnType<typeof toString>>(data)(
    factor(toString)
  )({});
  expect(result).toStrictEqual({
    num: "1",
    str: "hello",
    date: "Sat Sep 02 2023 00:30:10 GMT+0900 (日本標準時)",
    isManager: "false",
    obj: {
      num: "2",
      str: "world",
      isManager: "true",
      obj: { num: "3", str: "!" },
    },
  });
});

test("toPrettyMessage", () => {
  const toPrettyMessage = (d: Node) => `Pretty [${d.val as string}]` as const;
  const result = traverse<Demo, ReturnType<typeof toPrettyMessage>>(data)(
    factor(toPrettyMessage)
  )({});
  expect(result).toStrictEqual({
    num: "Pretty [1]",
    str: "Pretty [hello]",
    date: "Pretty [Sat Sep 02 2023 00:30:10 GMT+0900 (日本標準時)]",
    isManager: "Pretty [false]",
    obj: {
      num: "Pretty [2]",
      str: "Pretty [world]",
      isManager: "Pretty [true]",
      obj: { num: "Pretty [3]", str: "Pretty [!]" },
    },
  });
});

demo code.

https://codesandbox.io/p/sandbox/vigilant-greider-kqhrm7?file=/src/index.ts:1,25

簡単ですが、以上です。