🔥

ネストされたオブジェクトのデータを変換する処理を簡単にするパッケージを作成

2023/02/12に公開

始めに

APIやlocalStorageからデータを取得する際、日付データが欲しい場合はconvert処理を加える必要があると思います。フラットなオブジェクトならまだしもネストされたオブジェクトだったり、オブジェクト配列だったりすると変換するのも一苦労だと思います。

オブジェクトの一部を日付に変換
// まだ簡単な日付の変換
const simpleObj = {
  num: 0,
  date: '2023-01-14T02:03:03.956Z',
};
const convertedSimpleObj = {
  ...simpleObj,
  date: new Date(simpleObj.date)
}

// オブジェクト配列とかも変換するとなると大分面倒。。
const arrObj = {
  nums: [0, 1, 2],
  arrDate: ['2023-01-14T02:03:03.956Z', '2023-01-14T02:03:03.956Z'],
  arrObj: [
    {
      text: '',
      arrObjDate: '2023-01-14T02:03:03.956Z',
    },
  ],
};
const convertedArrObj = {
  ...arrObj,
  arrDate: arrObj.arrDate.map((dateStr) => new Date(dateStr)),
  arrObj: arrObj.arrObj.map((obj) => ({
    ...obj,
    arrObjDate: new Date(obj.arrObjDate),
  })
}

こういった変換を簡単に行いたくて、パッと調べた感じ良さそうなライブラリが無かったので作ってみました。

https://www.npmjs.com/package/@wintyo/data-converter

この記事ではこのパッケージの使い方の紹介と実装方法ついて説明したいと思います。

使い方

まずパッケージは@wintyo/data-converterでpublishしているのでこの名前でinstallします。

$ yarn add @wintyo/data-converter

1対1の変換

このパッケージはkeyとconverterが1対1で対応しており、変換したいkeyに対してconverterをセットすることで変換処理がされます。

1対1の変換
import { convert } from '@wintyo/data-converter';

const simpleObj = {
  num: 0,
  date: '2023-01-14T02:03:03.956Z',
};
const convertedSimpleObj = convert(simpleObj, {
  // ちゃんと型推論もしてくれてstring → Dateの変換になる
  date: (value) => new Date(value),
};

keyは配列やタプルにも対応しており、[][0]のように指定することでその項目に対する変換をすることができます。

配列に対する変換
const arrObj = {
  nums: [0, 1, 2],
  arrDate: ['2023-01-14T02:03:03.956Z', '2023-01-14T02:03:03.956Z'],
  arrObj: [
    {
      text: '',
      arrObjDate: '2023-01-14T02:03:03.956Z',
    },
  ],
};
const convertedArrObj = convert(arrObj, {
  'arrDate[]': (value) => new Date(value),
  'arrObj[].arrObjDate': (value) => new Date(value),
};
タプルに対する変換
const tupleObj = {
  tupleDate: ['2023-01-14T02:03:03.956Z', '2023-01-14T02:03:03.956Z'] as [
    string,
    string
  ],
  tupleObj: [
    {
      text: '',
      tupleObjDate: '2023-01-14T02:03:03.956Z',
    },
    {
      num: 0,
      tupleObjDate: '2023-01-14T02:03:03.956Z',
    },
  ] as [
    { text: string; tupleObjDate: string },
    { num: number; tupleObjDate: string }
  ],
}
const convertedTupleObj = convert(tupleObj, {
  // 'tupleDate[0]', 'tupleDate[1]'と指定してそれぞれを日付に変換しても良いが、
  // 'tupleDate'を指定して文字列タプルを受け取って日付タプルを返す書き方も可能
  tupleDate: ([value1, value2]) => [new Date(value1), new Date(value2)],
  // 0番目のtupleObjにあるtupleObjDateを変換
  'tupleObj[0].tupleObjDate': (value) => new Date(value)

特定の変換だけ行う

上記のやり方でも大分変換処理が書きやすくなったと思いますが、日付の変換だけしたいのに毎回(value) => new Date(value)と書くのは手間です。このように先に変換したい処理を書いて、その処理を適応させたいkeyを書くパターンも用意しています。

日付変換処理を先に定義し、適応させたいkeyを指定して変換する
import { createSpecificConverter } from '@wintyo/data-converter';

// 文字列から日付を変換する処理を先に定義
const convertDate = createSpecificConverter((value: string) => new Date(value));

// この変換処理を適応させるkeyリストを指定して変換する
// キーリストがstring[]にならないように最後に`as const`をつける
const convertedSimpleObj = convertDate(simpleObj, ['date'] as const)
const convertedArrObj = convertDate(arrObj, [
  'arrDate[]',
  'arrObj[].arrObjDate',
] as const);
const convertedTupleObj = convertDate(tupleObj, [
  // string → Dateの変換処理であるためそれ以外のキーを指定するとtypeエラーになる
  // 'tupleDate' // [string, string]型になるkeyなためtypeエラーになる
  'tupleDate[0]',
  'tupleObj[1].tupleObjDate'
] as const)

とても深いオブジェクトを変換したい場合

型推論における再帰上限の関係上、keyの指定は3階層までを限界としています。例えばdepth1.depth2.depth3arr[][]tuple[0].numのようなものです。これ以上深いオブジェクトを変換したい場合は3階層まで降りた値から再度convertを呼んでください。

とても深いオブジェクトで変換する
const veryDeepObj = {
  depth1: {
    depth2: {
      depth3: {
        depth4: {
          depth5: {
            num: 0,
            date: '2023-01-14T02:03:03.956Z',
          },
        },
      },
    },
  },
  deepArr: [[[[['2023-01-14T02:03:03.956Z']]]]],
};

const convertedData = convert(veryDeepObj, {
  // 3階層まで指定する
  'depth1.depth2.depth3': (obj) => {
    // 再度convertを呼んで目的のところまでconvertしていく
    return convert(obj, {
      'depth4.depth5.date': (value) => new Date(value),
    });
  },
  // 配列パターンの例
  'deepArr[][]': (arr) => {
    return convert(arr, {
      '[][][]': (value) => new Date(value),
    });
  },
});

実装方法

ここからは具体的にどう実装したかについて説明します。この内容を知らなくてもパッケージは使うことできますので、興味のある方だけこのセクションはご参照ください。

convertメソッドについて

convert処理するには主に以下のutility typeを作る必要があります。

  • convert対象のオブジェクトのkey pathとvalueのマッピング型を取得する (ObjectKeyValueMap)
  • 変換すべきkeyを指定でき、そのkeyに応じたvalueの型が推論されるようにする (GetConverterSet)
  • convert対象のオブジェクトと変換セットとなるconverterSetを元に変換後の型を算出する (ReturnConvertedType)

まずはこれらを順番に説明し、最後にJSの実装の方も説明します。

ObjectKeyValueMap

まずは変換対象のオブジェクトに対して、使い方で説明したようなkey pathとそのパスで取得されるvalueの型をマッピングしたものを作る必要があります。動作のイメージは以下のようになります。

ObjectKeyValueMapの動作イメージ
const nestObj = {
  num: number;
  date: string;
  obj: {
    hoge: string;
    fuga: number
  }
}
type Result = ObjectKeyValueMap<typeof nestObj>
/**
 * {
 *   num: number;
 *   date: string;
 *   'obj.hoge': string;
 *   'obj.fuga': number;
 * }
 */

実装の基本としてはこちらの記事を参考にします。この記事にあるObjectKeyPathsGetTypeByPathを使うことで実現はできますが、再帰処理の負担が大きくてそのまま使うことができませんでした。

https://zenn.dev/wintyo/articles/ba8cc9a8910381

そこでObjectKeyPathsをカスタマイズしてkey pathとvalueのタプルをunionで返すように調整し、それをオブジェクトに変換して実現します。

key pathとvalueのタプルunionを作ってからオブジェクトに変換
type KeyValueUnion = ObjectKeyValueUnion<typeof nestObj>
// ['num', number] | ['date', string] | ['obj.hoge',string] | ['obj.fuga', number]を返すようにする

// タプルunionからオブジェクトに変換
type TupleUnionToObject<TupleUnion extends [any, any]> = {
  [Tuple in TupleUnion as Tuple[0]]: Tuple[1];
};

// 上記のObjectKeyValueMapと同じ結果が出る
type Result = TupleUnionToObject<KeyValueUnion>;

肝心のObjectKeyValueUnionの実装は以下のような感じになります。コードが複雑すぎるので配列とタプルのケースは省略しています。完全版のコードを見たい方はライブラリのコードをご参照ください。
SearchableDepthは使い方の説明でも少しあがった型推論の再帰の負担を減らすためにオブジェクト階層の上限を設けるために設定しています。

ObjectKeyValueUnion
type PrevNum = [never, 0, 1, 2, 3, ...never[]];

type JoinObjectKey<
  CurrentPath extends string,
  AppendKey extends string
> = CurrentPath extends '' ? AppendKey : `${CurrentPath}.${AppendKey}`;

type ObjectKeyValueUnion<
  T extends object,
  CurrentPath extends string = '',
  SearchableDepth extends number = 3
> = [SearchableDepth] extends [never]
  ? never
  // 配列、タプルパターンは省略
  // オブジェクトパターン
  : {
    [K in keyof T]: K extends string
      ?
        | [JoinObjectKey<CurrentPath, K>, T[K]]
	| (T[K] extends object
	   ? ObjectKeyValueUnion<
	       T[K],
	       JoinObjectKey<CurrentPath, K>,
	       PrevNum[SearchableDepth]
	     >
	   : never)
      : never;
  }[keyof T];

GetConverterSet

KeyValueMapが得られたのでそれを元に設定できるconverterセットを定義します。

type GetConverterSet<
  T extends object,
  KeyValueMap extends ObjectKeyValueMap<T> = ObjectKeyValueMap<T>
> = Partial<{
  [K in keyof KeyValueMap]: (value: KeyValueMap[K]) => any;
}>;

ReturnConvertedType

与えられたKeyValueMapと設定したconverterオブジェクトセットを元に、最終的に変換された型を求めます。メソッドの返り値の型はReturnTypeで取得できるため、ConverterSetから上手く抽出してChangeTypeByKeyValueSetに渡します。ChangeTypeByKeyValueSetは後述します。

type ReturnConvertedType<
  T extends object,
  ConverterSet extends Partial<Record<string, (value: any) => any>>
> = ChangeTypeByKeyValueSet<
  T,
  {
    [K in keyof ConverterSet]: ReturnType<Exclude<ConverterSet[K], undefined>>;
  }
>;

ChangeTypeByKeyValueSetは文字通りKeyValueSetで指定したkeyをvalueの型に差し替えるものです。先ほど紹介した記事のやり方と同じように1階層ずつ降りながら該当するkeyかチェックして、そうでなければ元々の型を、マッチしたら変換したいvalueの型を返すようにしています。こちらもコードが長くなってしまうので配列とタプルパターンは省略しています。

ChangeTypeByKeyValueSet
type FilterStartsWith<
  S extends PropertyKey,
  Start extends string
> = S extends `${Start}${infer Rest}` ? S : never;

type ChangeTypeByKeyValueSetImpl<
  T,
  KeyValueSet extends Record<string, any>,
  CurrentPath extends string = '',
  SearchableDepth extends number = 3
> = [SearchableDepth] extends [never]
  ? // 探索階層に限界を達したら現在のTを返す
    T
  : FilterStartsWith<keyof KeyValueSet, CurrentPath> extends never
  ? // CurrentPathがKeyValueSetに一つも前方一致しない場合はこれ以上降りてもマッチしないため現在のTを返す
    T
  : CurrentPath extends keyof KeyValueSet
  ? // CurrentPathがKeyValueSetに完全にマッチした場合は対応するValueを返す
    KeyValueSet[CurrentPath]
  // 配列、タプルの場合は省略
  // オブジェクトかチェック。違う場合は何もせず現在のTを返す
  : keyof T extends never
  ? T
  : // オブジェクトの場合
    {
      [K in keyof T]: K extends string
        ? ChangeTypeByKeyValueSetImpl<
            T[K],
            KeyValueSet,
            JoinObjectKey<CurrentPath, K>,
            PrevNum[SearchableDepth]
          >
        : T[K];
    };

export type ChangeTypeByKeyValueSet<
  T extends object,
  KeyValueSet extends Partial<{
    [K in ObjectKeyPaths<T, SearchableDepth>]: any;
  }>,
  SearchableDepth extends number = 3
> =
  // ObjectKeyPathsに含まれないkeyが含まれていたらエラーと気づけるようにneverを返す
  Exclude<keyof KeyValueSet, ObjectKeyPaths<T>> extends never
    ? ChangeTypeByKeyValueSetImpl<T, KeyValueSet, '', SearchableDepth>
    : never;

convertメソッドのまとめと実装

以上の内容をまとめるとconvertメソッドのインターフェースは以下のようになります。詳細の実装はconvertImplに委ねます。

type GetConverterSet<
  T extends object,
  KeyValueMap extends ObjectKeyValueMap<T> = ObjectKeyValueMap<T>
> = Partial<{
  [K in keyof KeyValueMap]: (value: KeyValueMap[K]) => any;
}>;

const convert = <
  T extends object,
  ConverterSet extends GetConverterSet<T>
>(
  obj: T,
  converterSet: ConverterSet
): ReturnConvertedType<T, ConverterSet> => {
  return convertImpl(obj, converterSet);
};

convertImplの実装は以下のようになります。基本的には型の実装と同じようなやり方になっています。

convertImplの実装
const convertImpl = (
  obj: any,
  converterSet: Partial<Record<string, (value: any) => any>>,
  currentPath = ''
): any => {
  // currentPathがconverterSetに一つも前方一致していない場合は再帰しても仕方ないため変換せずに現在のobjを返す
  const filteredMatchedPaths = Object.keys(converterSet).filter((path) =>
    path.startsWith(currentPath)
  );
  if (filteredMatchedPaths.length <= 0) {
    return obj;
  }
  // currentPathと完全一致しているものがあった場合はそのconverterを実行する
  if (filteredMatchedPaths.includes(currentPath)) {
    const converter = converterSet[currentPath];
    return converter ? converter(obj) : obj;
  }

  // 配列とタプルは省略

  // オブジェクトの場合
  if (typeof obj === 'object') {
    const keys = Object.keys(obj);
    return Object.assign(
      {},
      ...keys.map((key) => ({
        [key]: convertImpl(
          obj[key],
          converterSet,
          joinObjectKey(currentPath, key)
        ),
      }))
    );
  }
  return obj;
};

createSpecificConverterメソッドについて

前セクションで説明したconvertメソッドを使い回して特定の変換処理に特化したconvertメソッドを生成するメソッドを作ります。使い方の説明にあったようにconverterを先に渡し、後からkeyを渡して変換するため、その形に合うように型の絞り込みや実装コードを呼び出します。

実装の概要

実装は以下の通りで、最初にconverterだけ受け取ります。これの結果としてconvertメソッドを返しますが、第2引数はkeysに変わります。keysはconverterで設定したvalueの型のみ受け付けるようにし、FilteredObjectKeyPathsについての実装は後述します。ここまでできればあとは全てのkeysに事前に受け取っているconverterをセットしたconverterSetを生成し、これをconvertImplに渡して変換処理を行います。

createSpecificConverter
const createSpecificConverter = <Converter extends (value: any) => any>(
  converter: Converter
) => {
  return <
    T extends object,
    // Converterで期待する型になるKeyPathのみに制限
    Keys extends readonly FilteredObjectKeyPaths<T, Parameters<Converter>[0]>[]
  >(
    obj: T,
    keys: Keys
  ): ChangeTypeByKeyValueSet<
    T,
    Record<Keys[number], ReturnType<Converter>>
  > => {
    const converterSet: Record<Keys[number], Converter> = Object.assign(
      {},
      ...keys.map((key) => ({
        [key]: converter,
      }))
    );
    return convertImpl(obj, converterSet);
  };
};

FilteredObjectKeyPathsの実装

ObjectKeyPathsから指定した型になるものだけfilterした型で、以下のようになります。

type FilteredObjectKeyPathsImpl<
  T extends object,
  TargetType,
  KeyPaths extends ObjectKeyPaths<T>
> = {
  [K in KeyPaths]: GetTypeByPath<T, K> extends TargetType ? K : never;
}[KeyPaths];

type FilteredObjectKeyPaths<
  T extends object,
  TargetType,
  SearchableDepth extends number = 3
> = FilteredObjectKeyPathsImpl<
  T,
  TargetType,
  ObjectKeyPaths<T, SearchableDepth>
>;

備考
ObjectKeyPathsで生成したKeyPathに対してGetTypeByPathを呼んでいるため大分再帰呼出ししちゃっています。なんでここだけエラーにならなかったんだろう。。一旦動いていますが、そのうち改修するかもしれません。

終わりに

以上がネストされたオブジェクトを簡単に変換処理できるようにしたdata-converterパッケージの紹介でした。実装は大分難しかったですが、パッケージ化したことで利用者目線では気軽に使えるようになったと思います。もし変換処理で辛さを感じている方は使って貰えると嬉しいです。
パッケージ作成はそこまで慣れていないので作り方に問題あったり、ライブラリ自体にバグがあったりしたら是非この記事か以下のGitHubのIssueにコメントしてもらえると助かります(英語も相当自信がないのでその辺のコメントも大歓迎です)。

https://github.com/wintyo/data-converter/tree/main

Discussion