はじめに
TypeScriptを使ったフロントエンドの開発をしていると特定の条件下でのオブジェクト同士の等価性をチェックする必要がある場面が時々ある
もちろんその場で普通にベタ打ちによるチェックで対応することで何も問題なく対処が可能であるが、個人的に型クラスを使ってみる練習をしてみたいということもあり、[fp-ts](GitHub - gcanti/fp-ts: Functional programming in TypeScript)を使うとどうなるかというのをとりあえずメモ的に残して置きたいので投稿した
前提
- 型クラスについては知っている
- TypeScriptを使ったことがある
単純なオブジェクトの比較
以下のようにPoint
型のプロパティの値が同じであるものが配列に含まれていることをチェックする場合
type Point = {
x: number;
y: number;
};
const point1 = {
x: 10,
y: 10
};
const point2 = {
x: 10,
y: 20
};
const point3 = {
x: 10,
y: 10
};
[point1, point2].includes(point3);
// <- false
// 参考
point1 === point3
// <- false
- 単純にオブジェクトの中身が同じであっても
false
が返る - この例において
true
を返すようにするには、各々のプロパティを比較するか文字列にして比較するなどして、自分で実装する方法があるが、今回はfp-tsの[Eq](Getting started with fp-ts: Eq - DEV)を使って実装してみたいと思う
Eq
- fp-tsでは型クラスを
interface
によって定義されている - シグニチャは以下
export interface Eq<A> {
readonly equals: (x: A, y: A) => boolean
}
-
Eq
型はequals
のみを実装の要求がされている -
number
、string
、boolean
などに対するEq
型のインスタンスは用意されている
export declare const eqString: Eq<string>
export declare const eqNumber: Eq<number>
export declare const eqBoolean: Eq<boolean>
Eq
を使った関数の定義
- 単純に
Eq
を定義するだけだとあまり効果がない - 大事なのは
Eq
のインスタンスが定義されれば例外なく適応できる関数を定義できることである - 以下は
includes
を改良した例(Eq
のドキュメントに載っているのをほとんどそのまま流用)
const includes = <A>(E: Eq<A>) => (a: A, arr: Array<A>) => arr.some(item => E.equals(item, a));
Point
型に対するEq
の定義
愚直に定義してみる
import { Eq } from "fp-ts/lib/Eq"
const eqPoint: Eq<Point> = ({
equals: (p1, p2) => p1 === p2 || (p1.x === p2.x && p1.y === p2.y)
})
- これだけで一応定義できたことになる
getStructEq
を使う
- 今回は単純にプロパティの比較が実現できれば問題ないが如何せん手入力も多くバグの温床になりやすい
- その場合に
getStructEq<Uesr>
を使うと安全にEq<User>
のインスタンスが手に入る -
getStructEq
のジェネリクスに渡した型の各々のプロパティに対して、任意のEq
のインスタンスを値とすると、戻り値のEq
のインスタンスのequals
の実装は、各々のプロパティのequals
を組み合わせた実装になっている - 文章の説明よりコードを見たほうが理解しやすいので、以下例
import { Eq, eqNumber, getStructEq } from "fp-ts/lib/Eq"
const eqPoint: Eq<Point> = getStructEq<Point>({
x: eqNumber,
y: eqNumber
})
ネストしたオブジェクトのEq
の定義
- 今までの例では
Point
を使ってみたが、単純な型なのでネストのある型で考える
import { getEq } from "fp-ts/lib/Array"
import { Eq, eqString, getStructEq } from "fp-ts/lib/Eq"
type User = {
name: Name;
friends: Pick<User, "name">[];
};
type Name = {
firstName: string;
lastName: string;
};
const eqName = getStructEq<Name>({
firstName: eqString,
lastName: eqString
});
const eqFriend: Eq<Pick<User, "name">> = getStructEq<Pick<User, "name">>({
name: eqName
});
const eqFriends: Eq<Array<Pick<User, "name">>> = getEq(eqFriend);
const eqUser = getStructEq<User>({
name: eqName,
friends: eqFriends
});
-
eqName
自体は前のeqPoint
と同じ感じだが、このインスタンス自体も当然getStructEq
で組み合わせるのに使うことができる - また特定の
Eq<A>
からEq<Array<A>>
はfp-ts/lib/Array
モジュールのgetEq
関数から得ることができる
特定の値の等価からEq
を定義する
- 今まですべての値の等価をチェックしていたが、そこまで比較しなくても一意に定まるもので比較すれば十分である場合を考える
type ToDo = {
id: number;
title: string;
content: string;
};
- この場合
id
が同じであれば等価であると定義したい
愚直に定義してみる
const eqToDo: Eq<ToDo> = {
equals: (x, y) => x.id === y.id
};
contramap
を使って定義する
-
contramap
を使うともっと単純にeqToDo
が得られる -
contramap
のシグニチャは以下
export declare const contramap: <A, B>(f: (b: B) => A) => (fa: Eq<A>) => Eq<B>
-
contramap
は型クラスを勉強していると出てくるが、定義したいB
のEq
を関数B => A
とA
のEq
を与えることで、A
のEq
に落とし込むことができる - 自前での実装はおそらく以下の感じ
import { Eq } from "fp-ts/lib/Eq"
const contramap = <A, B>(f: (b: B) => A) => (fa: Eq<A>): Eq<B> => ({
equals: (x, y) => fa.equals(f(x), f(y))
})
-
eqToDo
の書き換えは以下
import { contramap, eqNumber } from "fp-ts/lib/Eq"
const eqToDo = contramap<number, ToDo>(b => b.id)(eqNumber)
実際にincludes
を使ってみる
- ここまでで3つの
Eq
のインスタンスを定義してみたが、このEq
を使って前に定義したincludes
が適用できることを確認してみる
Point
// point1とpoint3の値が同じ
const point1 = {
x: 10,
y: 10
};
const point2 = {
x: 100,
y: 100
};
const point3 = {
x: 10,
y: 10
};
const points = [point1, point2];
includes(eqPoint)(point3, points);
// <- true
User
// user1とuser3の値が同じ
export const user1: User = {
name: {
firstName: "first",
lastName: "last"
},
friends: [
{ name: { firstName: "first2", lastName: "last2" } },
{ name: { firstName: "first3", lastName: "last3" } }
]
};
export const user2: User = {
name: {
firstName: "first2",
lastName: "last2"
},
friends: [
{ name: { firstName: "first", lastName: "last" } }
]
};
export const user3: User = {
name: {
firstName: "first",
lastName: "last"
},
friends: [
{ name: { firstName: "first2", lastName: "last2" } },
{ name: { firstName: "first3", lastName: "last3" } }
]
};
const users = [user1, user2];
inclues(eqUser)(user3, users);
// <- true
ToDo
// toDo1とtoDo3のidが同じ
const toDo1: ToDo = {
id: 1,
title: "test",
content: "foo"
};
const toDo2: ToDo = {
id: 2,
title: "test",
content: "foo"
};
const toDo3: ToDo = {
id: 1,
title: "test2",
content: "bar"
};
const toDoList = [toDo1, toDo2];
includes(eqToDo)(toDo3, toDoList);
// <- true
終わりに
個人的にはeqString.equal
を使うなど、わざわざプリミティブな型に対してはそこまでするする必要もないとは思うが、複雑な構造をした型に対しては常に意識して使ったほうがメリットがある場合は積極的に使っていきたい