Edited at

TypeScriptで型安全なScalaのcase classのcopyを作る


経緯

最近、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で言えば、以下のようなことです。

ageInt型なので、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


コンソール出力

Screen Shot 2017-08-07 at 23.20.09.png

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が入ってしまっています...

Screen Shot 2017-08-07 at 23.38.43.png

(次が本題のcopyメソッドを作る話で、そこではこの問題は解消できます)


追記(2019/05/14)

現在、TypeScript 3.4.5で試したところ、ちゃんと知らないプロパティあることが、コンパイル時に弾けることが分かりました。(型システムが変わったのか、オプションなどによるものかは未調査です。)


image.png


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です。


コンソール出力

Screen Shot 2017-08-07 at 23.29.26.png


本当に型安全?

以下のことがコンパイルエラーとして摘出できました。

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);


コンソール出力

Screen Shot 2017-08-08 at 1.00.39.png


mapCopyの型安全性

以下のことをするとちゃんとコンパイルエラーしてくれます

ageの変更後が文字列になるとき

// compile error

const person3 = person1.mapCopy({age: prev => prev + "str"});

存在しないプロパティのとき

// compile error

const person3 = person1.mapCopy({somethingElse: prev => prev + 1});


追記

ライブラリ化しました

https://www.npmjs.com/package/ts-copyable

npm install --save ts-copyable