JavaScriptで副作用なくオブジェクトを操作したい


関数型言語にふれてから再代入見るのが嫌になった系エンジニアです。

こんな感じのオブジェクトとがあるとします。

const family = {
    parents: {
        father: { name:"titi", age: 34 },
        mother: { name:"haha", age: 34 }
    },
    childlen: [
        { name:"musuko", age: 2 },
        { name:"musume", age: 0 }
    ]
};

そしてこう思うのです。
motherのageだけ変更したオブジェクトが欲しいな、、と。

Object.assignでやってみる

const newFamily = Object.assign({}, family)
newFamily.parents.mother.age = 26;

これでmotherの年齢だけ変更されたnewFamilyができましたね。
familyオブジェクトとnewFamilyオブジェクトを見てみましょう。

familyオブジェクト

{
     parents: {
         father: { name: "titi", age: 34 },
         mother: { name: "haha", age: 26 }
     },
     childlen: [
         { name: "musuko", age: 2 },
         { name: "musume", age: 0 }
     ]
}

newFamilyオブジェクト

{
     parents: {
         father: { name: "titi", age: 34 },
         mother: { name: "haha", age: 26 }
     },
     childlen: [
         { name: "musuko", age: 2 },
         { name: "musume", age: 0 }
     ]
}

おおっと!?
familyの奥さんも若くなっとるやん。

調べてみたらObject.assignはプリミティブ値以外はシャローコピーになるみたいでした。
そもそもnewFamilyとして定義した後にプロパティを代入するのがなんか嫌ですね...
どうせならnewFamilyの宣言と値の更新を同時にやりたい....

作ってみました

const cloneDeeply = (src) => JSON.parse(JSON.stringify(src));

const replaceObjNode = (obj, properties, newNode)=> {
    const objTree = properties.reduce(
        (acc, prop) => [...acc, cloneDeeply(acc.slice(-1)[0][prop])],
        [cloneDeeply(obj)]
    );
    return objTree
        .slice(0, -1)
        .reduceRight(
            (acc, cur, i) => Object.assign(cur, {[properties[i]]: acc }),
            newNode
        );
};

解説していきます。

cloneDeeply

const cloneDeeply = (src) => JSON.parse(JSON.stringify(src));

まんまです、プリミティブ値以外もdeepに複製します。
実はObject.asssignのドキュメントに記載されていたものをパクっただけです。。。。

replaceObjNode

・変更元となるオブジェクト
・変更するプロパティまでのパスとして文字列配列
・変更したい値

を引数に取り値変更後の新しいオブジェクトを生成します。

const replaceObjNode = (obj: , properties, newNode)=> {
    const objTree = properties.reduce(
        (acc, prop) => [...acc, cloneDeeply(acc.slice(-1)[0][prop])],
        [cloneDeeply(obj)]
    );
    return objTree
        .slice(0, -1)
        .reduceRight(
            (acc, cur, i) => Object.assign(cur, {[properties[i]]: acc }),
            newNode
        );
};

objTree配列から見ていきますと。
まずreduceのコールバック内でproperties配列の各要素propが変更元オブジェクトのプロパティに該当します。
acc.slice(-1)[0][prop]アキュムレータ配列の最終要素のオブジェクトからプロパティを指定して要素を取り出します。

こんな感じで呼び出してみますと

const family = {
    parents: {
        father: { name:"titi", age: 34 },
        mother: { name:"haha", age: 34 }
    },
    childlen: [
        { name:"musuko", age: 2 },
        { name:"musume", age: 0 }
    ]
};

const newFamily = replaceObjNode(family, ["parents", "mother", "age"], 26);

デフォルトでは[cloneDeeply(obj)]を指定しているので下記配列の

[
    {
        "parents": { "father": { "name": "titi", "age": 34 }, "mother": { "name": "haha", "age": 34 } },
        "childlen": [{ "name": "musuko", "age": 2 }, { "name": "musume", "age": 0 }]
    }
]

最終要素オブジェクト内のparentプロパティが追加されます。

{ "father": { "name": "titi", "age": 34 }, "mother": { "name": "haha", "age": 34 } }

これを繰り返すことでproperties配列をobjectのツリー状配列に変換します。
最終的にこんな感じで展開されます

[
    {
        "parents": { "father": { "name": "titi", "age": 34 }, "mother": { "name": "haha", "age": 34 } },
        "childlen": [{ "name": "musuko", "age": 2 }, { "name": "musume", "age": 0 }]
    },
    {
        "father": { "name": "titi", "age": 34 },
        "mother": { "name": "haha", "age": 34 }
    },
    { "name": "haha", "age": 34 },
    34
]

obJTree配列作成後slice(0, -1)で差し替えたい値である最終要素を詰めた配列を再作成します。
その後差し替え先の値であるnewNodeをアキュムレータ初期値に指定し、右から畳み込んでいきます。

reduceRight内は下記のようにloopします。

//1loop
Object.assign({ "name": "haha", "age": 34}, { age: 26 })

//2loop
Object.assign(
    {
        "father": { "name": "titi", "age": 34 },
        "mother": { "name": "haha", "age": 34 }
    },
    { "mother": { "name": "haha", "age": 26 } }
)
//3loop
Object.assign(
    {
        "parents": { "father": { "name": "titi", "age": 34 }, "mother": { "name": "haha", "age": 34 } },
        "childlen": [{ "name": "musuko", "age": 2 }, { "name": "musume", "age": 0 }]
    },
    { "parents": { "father": { "name": "titi", "age": 34 }, "mother": { "name": "haha", "age": 26 } } }
)

//result
{
     parents: {
         father: { name: "titi", age: 34 },
         mother: { name: "haha", age: 26 }
     },
     childlen: [
         { name: "musuko", age: 2 },
         { name: "musume", age: 0 }
     ]
}

これで再代入なく一発で値更新後のオブジェクトを生成する関数の出来上がりです。
ただそこそこコストかかりそうですね。。。。。。

型もつけてみた

const cloneDeeply = <T>(src: T): T => JSON.parse(JSON.stringify(src));

const replaceObjNode = <T extends { [key: string]: any }, U>(obj: T, properties: [keyof T, ...string[]], newNode: U): T => {
    const objTree = (properties as string[]).reduce(
        (acc: { [key: string]: any }[], prop: string) => [...acc, cloneDeeply(acc.slice(-1)[0][prop])],
        [cloneDeeply(obj)]
    );
    return objTree
        .slice(0, -1)
        .reduceRight(
            (acc: { [key: string]: any } | any, cur: any, i: number) => Object.assign(cur, { [properties[i]]: acc }),
            newNode
        );
};

propertiesの一要素目はobjの一段目のプロパティ以外受け付けない文字列配列として制限しました。
propertiesがとりうる全パターンのユニオンタイプを作りたかったのですが力不足でできず。。。
というかそんなことできるんでしょうか???

newNodeも制限ゆるゆるなのでいろいろ突っ込みどころはありますが大目に見ていただけたら幸いです。。。。

おわり

これ書いているときにはじめてreduceRight使ったのですが、
reducerの第三引数のindexってちゃんと逆順になってるんですね。びっくりしました。

ライブラリ入れるほどじゃないけど似たようなことをしたい方がいましたら良ければ使ってみてください。
アドバイス等々ありましたらコメントいただけるととてもうれしいです。

最後までお読みいただきありがとうございました。