イミュータブルにオブジェクトを更新するためのライブラリを作った
要約
- イミュータブルなプログラミングをする際、オブジェクトを更新したいときは元のオブジェクトに変更を加えるのではなく、更新後の状態を返すことで更新を表現する
- 素の ECMAScript で「更新後の状態を返却」をやろうとするとスプレッド演算子だらけで読みにくいためライブラリを作った
はじめに
イミュータブルなプログラミングをする際、オブジェクトを更新したいときは元のオブジェクトに変更を加えるのではなく、更新後の状態を返すことで更新を表現すると思います。
ECMAScript でそれをやろうとすると、スプレッド演算子を使うか、何かしらの手段でディープコピーを行い、そのコピー後の変数に対し破壊的変更を行うやり方があると思います。
どちらのやり方もいささか不恰好なので、シュッといい感じにイミュータブルな更新を実現したく、ライブラリを作るに至りました。
今回作ったライブラリがこちらです。
使い方
基本的な使い方
以下の型 Person と Person 型の変数 person があるとします。
type Person = {
name: string;
age: number | null;
from: {
name: string;
category: string;
famous: {
people: string[];
place: {
park: string[];
};
};
};
living: {
name: string;
category: string;
famous: {
people: string[];
place: {
park: string[];
};
};
};
};
const person: Person = {
name: "John Smith",
age: 28,
from: {
name: "New york",
category: "City",
famous: {
people: ["Jay-Z", "Lady Gaga"],
place: {
park: ["Central Park", "Battery Park"],
},
},
},
living: {
name: "Los Angeles",
category: "City",
famous: {
people: ["Kobe Bryant", "LeBron James"],
place: {
park: ["Griffith Park"],
},
},
},
};
ここで、以下のようにperson
に変更を加えた変数personUpdated
を作りたい場合を考えます。
-
person.from.famous.people
に'Beyoncé'
を追加する -
person.living.famous.place.park
に'Echo Park'
を追加する -
person.age
が null でなければ+1 する -
person.from.name
を'New York'
に修正する
ライブラリを用いずに更新後の状態をつくろうとすると、以下のようになると思います。
const personUpdated: Person = {
...person,
age: person.age === null ? null : person.age + 1,
from: {
...person.from,
name: "New York",
famous: {
...person.from.famous,
people: [...person.from.famous.people, "Beyoncé"],
},
},
living: {
...person.living,
famous: {
...person.living.famous,
place: {
...person.living.famous.place,
park: [...person.living.famous.place.park, "Echo Park"],
},
},
},
};
これを読みにくいと感じない人は少数ではないでしょうか?
読みやすくするために、拙作のライブラリを使用してみましょう。
import { generateRecordUpdater } from "@mm1995tk/immutable-record-updater";
// step.1
const updater = generateRecordUpdater<Person>();
// step.2
const program = updater
.set("from.famous.people", item => [...item, "Beyoncé"])
.set("living.famous.place.park", item => [...item, "Echo Park"])
.set("from.name", "New York")
.setIfNotNullish("age", n => n + 1);
// step.3
const result = program.run(person); // { success: true, data: {..}}
if (result.success) {
const personUpdated: Person = result.data;
}
このように、どのように変更を加えたのかが一目瞭然になりました。
それでは、順を追って何をしているか解説していきます。
step.1
更新後の状態を作成するオブジェクト、updater を作成します。
この updater に、「オブジェクトをどのように更新するのか」を教えていくことで updater が更新後の状態を作成できるようになります。
step.2
.set または、.setIfNotNullish を使用して updater に、「オブジェクトをどのように更新するのか」を教えていきます。
第一引数は更新したいプロパティへのパスを指定し、第二引数に置き換える値または「第一引数で指定したプロパティの現在の状態と、オブジェクトの現在の状態を取得する関数を受けっとて新しい状態を返す関数」をとります。
余談ですが、ここは React Hook Form の型定義を利用しています。
.setIfNotNullish("age", n => n + 1)
に注目します。
ここで仮に.set を使うと、n はnumber | null
と推論されるため、.set("age", n => n === null ? null : n + 1)
のように書く必要があります。このように nullable なプロパティの取り扱いも便利なライブラリになっています。
ここまで、updater に対して「どのように更新後の状態を作成するかを教える」と表現してきました。これは updater に変更を加えているかのように聞こえますが、実際は updater を書き換えているわけではありません。.set や.setIfNotNullish がプロパティの更新の仕方を知った updater を返却しています。(Array.prototype.map をイメージしていただけると理解しやすいと思います。)そのため、変数に代入することで、この時点での状態を捕捉します。「どのように更新後の状態を作成するか」というのは「プロパティ更新の手順」ということもできるので、この変数を program と名付けます。
step.3
ここまでは「どのように更新後の状態を作成するか」という手順を作ってきただけで、実際にオブジェクトの更新は行なっていません。その証拠に、まだ更新元のオブジェクトを受け取っていません。
.run メソッドを使用して、更新前の値を受け取り、更新後の状態を返します。
.run メソッドの返り値の型は以下のようになります。
type Result<T extends FieldValues, Error> =
| {
success: true;
data: T;
}
| { success: false; errors: NonEmptyArray<Error>; invalidData: T };
いわゆるオレオレ Result 型です。
success が true なら、無事、更新後の状態を取り出すことができます。
応用的な使い方
制約をかける
Person 型のデータは以下の制約を常に満たさなければいけない場合を考えます。
- age が 30 以上
- name が 5 文字以下
まず、エラーを定義します。
type PersonError = "ageError" | "nameError";
generateRecordUpdater は満たすべき制約を伝えると、.run メソッドが実行される直前に制約をチェックします。
const updater = generateRecordUpdater<Person, PersonError>(
person => !person.age || person.age >= 30 || "ageError",
person => person.name.length <= 5 || "nameError"
);
const program = updater
.set("from.famous.people", item => [...item, "Beyoncé"])
.set("living.famous.place.park", item => [...item, "Echo Park"]);
const result = program.run(person); // { success: false, errors: ['ageError', 'nameError'], ...others }
例外を投げないため、リテラルでエラーの種類を定義しましたが、組み込みのエラーオブジェクトを継承していれば、オブジェクトをエラーとして利用することもできます。
abstract class PersonError extends Error {}
class AgeError extends PersonError {}
class NameError extends PersonError {}
const updater = generateRecordUpdater<Person, PersonError>(
person =>
!person.age || person.age >= 30 || new AgeError("年齢が30未満です。"),
person =>
person.name.length <= 5 || new NameError("名前が5文字を超えています。")
);
オレオレ Result 型を使わず、エラー時は素直に例外を投げる
TypeScript は Scala や Haskell、Rust のように Result 型をうまく扱う構文を持っていないため、オレオレ Result 型を取り扱うとどうしてもコードが汚くなります。(筆者個人の意見です。)
複数のライブラリがそれぞれオレオレ Result 型を持ち、プロジェクト内にもオレオレ Result 型が存在しようものなら混沌の極みです。(筆者個人の意見です。)
上述より、筆者は TypeScript では素直に例外を投げるのがよいと考えており、そのための.runOrThrow メソッドを用意しています。
.runOrThrow メソッドは第一引数に更新前の状態のオブジェクトをとり、第二引数にエラー内容を受け取り、それを元に例外を投げる関数を受け取ります。
const personUpdated: Person = program.runOrThrow(person, errors => {
// ここで何かしら例外を投げる
});
第二引数の関数の返り値の型 never にして、無限ループするか例外を投げるかしかできなくすることで、実質例外を投げることを強制させています。
例外発生箇所を追いやすくするため、このような形で例外をユーザー側に投げさせる判断をしました。
終わりに
プルリク・イシューお待ちしております。
Discussion