ネストされたオブジェクトのデータを変換する処理を簡単にするパッケージを作成
始めに
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),
})
}
こういった変換を簡単に行いたくて、パッと調べた感じ良さそうなライブラリが無かったので作ってみました。
この記事ではこのパッケージの使い方の紹介と実装方法ついて説明したいと思います。
使い方
まずパッケージは@wintyo/data-converter
でpublishしているのでこの名前でinstallします。
$ yarn add @wintyo/data-converter
1対1の変換
このパッケージはkeyとconverterが1対1で対応しており、変換したいkeyに対してconverterをセットすることで変換処理がされます。
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を書くパターンも用意しています。
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.depth3
、arr[][]
、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の型をマッピングしたものを作る必要があります。動作のイメージは以下のようになります。
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;
* }
*/
実装の基本としてはこちらの記事を参考にします。この記事にあるObjectKeyPaths
とGetTypeByPath
を使うことで実現はできますが、再帰処理の負担が大きくてそのまま使うことができませんでした。
そこでObjectKeyPaths
をカスタマイズして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
は使い方の説明でも少しあがった型推論の再帰の負担を減らすためにオブジェクト階層の上限を設けるために設定しています。
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の型を返すようにしています。こちらもコードが長くなってしまうので配列とタプルパターンは省略しています。
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の実装は以下のようになります。基本的には型の実装と同じようなやり方になっています。
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
に渡して変換処理を行います。
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にコメントしてもらえると助かります(英語も相当自信がないのでその辺のコメントも大歓迎です)。
Discussion