経緯
最近、TypeScriptを初めて、Reduxを動画でチュートリアルをみて、イミュータブルなデータを扱っていく感じがしたので、「これはScalaのcopy
が必要だな!」と思いました。
copy
の必要性を感じた理由は、破壊的な変更をする思想ではないので、オブジェクトの一部を変更したいときに、変更後を返すのが自然だと思ったからです。
ところが、TypeScriptにScalaのcase classにあるcopy
に相当するものが見つけられませんでした...
ということで、copy
に相当するものを作ることにしました。
Scalaのcopyってなんこと?
(Scalaを触ったことある方は読み飛ばしてください)
Scala以外だとHaskellでしか、これと似た機能を持った言語を知らないので、代表して、Scalaのcopy
と言ってます。他の言語にもあることを知っている方は「OOO言語のxxxね」って思ってください。
copy
の目的は、「破壊的に変更せずに、変更後の値を返す」こと です。
具体的な例
// 人クラスの宣言
case class Person(name: String, age: Int)
object Main extends App{
// person1をインスタンス化
val person1 = Person("jack", 2)
// person1.ageを85に変更をしたものを作成
val person2 = person1.copy(age=85)
println(person1)
println(person2)
}
出力
Person(jack,2)
Person(jack,85)
person1
を破壊的に変更するという発想ではなく、person1
部分変更がperson2
になっていることがわかります。
余談
「定義もしてないのに、なんでcopy
使えるの?」と思った方に。
Scalaはcase classにすると、便利なメソッドを自動で適切に定義してくれる言語になってます。copy
の他にもいくつかの便利な関数を適切に定義してくれます。
目標
- 型安全
- 簡単に導入できる
型安全と言うのは、「型システムを使って、やってはいけないことをコンパイル時に摘出する」という意味で書いてます。
具体的には?
「やってはいけないこと」という意味は、Scalaで言えば、以下のようなことです。
age
はInt
型なので、String
コンパイル時にエラーで間違いを教えてくれます。
// compile error
person1.copy(age="this is a string")
また、以下のように存在しないメンバを変更しようとした時もコンパイルエラーが欲しいですね。
// compile error
person1.copy(somethingElse=99)
interface
を使った実現方法
...
というSpread Operatorというものを利用していると思います。
特に特別なことも必要なくとてもシンプルです。
interface IPerson{
name: string;
age : number;
}
const person1: IPerson = {name: "jack", age: 2};
const person2: IPerson = {...person1, age: 85};
console.log(person1)
console.log(person2);
参考: https://github.com/Microsoft/TypeScript/issues/7437
コンソール出力
Playgroundでも動きました。https://www.typescriptlang.org/play/
本当に型安全?
以下はコンパイルエラーで弾けます!
-
age
の型違い
// compile error
const person2: IPerson = {...person1, age: "this is a string"};
- ベースにするオブジェクトの違い
// compile error
const wrongPerson1 = {firstName: "Jack", lastName: "Sparrow"}
const person2: IPerson = {...wrongPerson1, age: 85};
コンパイルエラーで弾けないもの
残念ながら、これはコンパイルエラーで弾けませんでした。
const person2: IPerson = {...person1, somethingElse: 85};
出力をみると、たしかにsomethingElse
が入ってしまっています...
(次が本題のcopy
メソッドを作る話で、そこではこの問題は解消できます)
追記(2019/05/14)
現在、TypeScript 3.4.5で試したところ、ちゃんと知らないプロパティあることが、コンパイル時に弾けることが分かりました。(型システムが変わったのか、オプションなどによるものかは未調査です。)
Copyable
クラスを作る!
以下がCopyable
クラスの全部です。
// (from: http://blog.yux3.net/entry/2017/02/08/033834)
type Constructable<T> = new(...args: any[]) => T;
class Copyable<T>{
constructor(private _constructor: Constructable<T>){
}
copy(partial: Partial<T>): T{
const cloneObj: T = new this._constructor(); // (from: https://stackoverflow.com/a/17383858/2885946)
return Object.assign(cloneObj, this, partial);
}
}
(from
コメントはソースコード中の参考にしたサイトたちです)
Copyable
の使用例
class Person extends Copyable<Person>{
constructor(public name: string, public age: number){
super(Person);
}
}
const person1 = new Person("jack", 2);
const person2 = person1.copy({ age: 85 });
console.log(person1);
console.log(person2);
こちらのコードもPlaygroundでも動きました。https://www.typescriptlang.org/play/
注意) Copyable
は自作クラスです。実行するときは上のCopyable
の定義もコピペしてください。
注意) public
はプロパティの明示的な代入をするの省くためにやってます。public
の代わりにreadonly
でもOKです。
コンソール出力
本当に型安全?
以下のことがコンパイルエラーとして摘出できました。
age
の型違い
// compile error
const person2 = person1.copy({ age: "this is a string" });
存在しないプロパティを変更しようとしたとき
// compile error
const person2 = person1.copy({ somethingElse: 99});
super
に渡すコンストラクタな関数(?)を間違えたとき
class WrongPerson{}
class Person extends Copyable<Person>{
constructor(public name: string, public age: number){
super(WrongPerson); // <= compiler error
}
}
Copyable
の使い方
以下のA
クラスにcopy
を生やしたいときは、
class A{
constructor(public a: number, public b: Boolean, public c: string) {
}
}
=>
extends Copyable<A>
とsuper(A);
を追加すればOKです。
class A extends Copyable<A>{
constructor(public a: number, public b: Boolean, public c: string) {
super(A);
}
}
注意点
public
はプロパティの代入をするの省くためにやってます。public
の代わりにreadonly
でもOKです。
実際はreadonly
のほうがimmutableなデータ構造にしたいときは、マッチしていると思いますしね。
Copyable
を実装するにあったて
おそらく、Partial<T>
っていうのが、Copyable
の型安全性にとても寄与してくれてると思います。意味は「部分的なプロパティと値の集まり」みたいな感じだと思います。
Partial
は以下のブロクで知りました。以下のブログさんでは他に詳しく記述が書いてあって、
type Constructable<T>
もこちらのブログさんの違う記事で見つけたものを拝借させていただきましたm(_ _)m
追記
前の値が欲しくなるときがあると思いそれを満たすためにCopyable
を拡張しました。
変更点は
-
PartialMap<T>
という自作の型の定義 - 新しく
mapCopy
という名前のメソッド
以下はmapCopy
追加後のCopyable
クラスです。
type PartialMap<T> = {
[P in keyof T]?: (prev: T[P]) => T[P];
};
type Constructable<T> = new(...args: any[]) => T;
class Copyable<T>{
constructor(private _constructor: Constructable<T>){
}
copy(partial: Partial<T>): T{
const cloneObj: T = new this._constructor(); // (from: https://stackoverflow.com/a/17383858/2885946)
return Object.assign(cloneObj, this, partial);
}
mapCopy(partial: PartialMap<T>): T {
const cloneObj: T = new this._constructor(); // (from: https://stackoverflow.com/a/17383858/2885946)
for (const key of Object.keys(this)) {
if (key in partial) {
cloneObj[key] = (partial as any)[key](this[key]);
} else {
cloneObj[key] = this[key];
}
}
return cloneObj;
}
}
mapCopy
の使い方
const person1 = new Person("jack", 2);
const person3 = person1.mapCopy({age: prev => prev +1});
console.log(person1);
console.log(person3);
コンソール出力
mapCopy
の型安全性
以下のことをするとちゃんとコンパイルエラーしてくれます
ageの変更後が文字列になるとき
// compile error
const person3 = person1.mapCopy({age: prev => prev + "str"});
存在しないプロパティのとき
// compile error
const person3 = person1.mapCopy({somethingElse: prev => prev + 1});
追記
ライブラリ化しました
npm install --save ts-copyable