クラス初期化オブジェクトを受け取るクラスを書く時の自分流パターンのメモです。
更新履歴
- (2020/04/17) 諸々更新
- (2019/03/26) ディープコピーする場合について追記
- (2019/03/19) デフォルト値とのorの取り方の改良版を追記
- interface
IUserをまず用意。
interface IUser {
id: string;
timestamp: number;
name: string;
data: number[];
}
- 初期化子
initが無いときのためのデフォルト値を作成- ただの定数ではなく関数にしているのは、毎回新たなオブジェクトを返させることによりデフォルト値オブジェクトの値が書き換えられる心配を無くすため。
- 例えば
dataのようなprimitiveではない値はconstructor内でdeep copyしていないと参照のコピーになるため後から書き換えられる可能性がある。 - 2回以上このconstructorが実行されるときや、constructor以外でこのデフォルト値を使うところがあるとき、定数のつもりで使っているデフォルト値の中身が書き換わっていると変なことになる。
- デフォルト値を
Object.freezeなどで書き換えられないようにする手もありそうだが、そのままだと浅いところまでしか固定してくれないし、結局constructor内でprimitive以外のディープコピーが必要になるだけなので、ここではあまりうれしくなかった(良い方法があれば教えてください)。毎回新たにデフォルト値オブジェクトを返してくれる関数を作ってしまうのが間違いが無くて簡単だと思う。
- 例えば
- ただの定数ではなく関数にしているのは、毎回新たなオブジェクトを返させることによりデフォルト値オブジェクトの値が書き換えられる心配を無くすため。
const defaultValues = (): IUser => ({
id : '',
timestamp : Date.now(),
name : '',
data : [],
});
(2020/04/17追記)
type ReadonlyUser = Readonly<{
id: string;
timestamp: number;
name: string;
data: readonly number[];
}>
const defaultValues: ReadonlyUser = {
id : '',
timestamp : Date.now(),
name : '',
data : [],
} as const;
で良いです。
- class
Userの定義-
implements IUserを忘れずに - constructor引数を
init: Partial<IUser> = defaultValues()とする-
initがundefinedのときにはデフォルト値に差し替わる -
Partialを付けたことでinitは一部のkeyのみ含むオブジェクトも渡せるように(含んでいないオブジェクトは後でデフォルト値で初期化する)
-
- 各keyを
this.key = (init.key || dfl.key);で初期化。- 同じような処理なので
Object.keys(this).forEach(...で回すことも考えたが、tslintに初期化忘れと怒られたのでやめた。1
- 同じような処理なので
-
class User implements IUser {
id: string;
timestamp: number;
name: string;
data: number[];
constructor(init: Partial<IUser> = defaultValues()) {
const dfl = defaultValues();
this.id = (init.id || dfl.id );
this.timestamp = (init.timestamp || dfl.timestamp);
this.name = (init.name || dfl.name );
this.data = (init.data || dfl.data );
}
}
補足
メンバーにオブジェクトを含むクラスを作りたいときは、Partial<IUser>だけだと一番浅いところにしか?が付かないので、必要に応じ深いところにも?を付けたinterfaceを適宜作ればよさそう。2
以下のようにOmitという型を作り、
IUserからメンバdataを省いたinterfaceを拡張してIUserPartialを以下のように作ればよさそうです。
export interface IUser {
id: string;
timestamp: number;
name: string;
data: { a: number, b: number };
}
export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
interface IUserPartial extends Partial<Omit<IUser, 'data'>> {
data?: {
a?: number,
b?: number,
};
}
const defaultValues = (): IUser => ({
id : '',
timestamp : Date.now(),
name : '',
data : { a: 0, b: 0 },
});
export class User implements IUser {
id: string;
timestamp: number;
name: string;
data: { a: number, b: number };
constructor(init: IUserPartial = defaultValues()) {
const dfl = defaultValues();
this.id = (init.id || dfl.id );
this.timestamp = (init.timestamp || dfl.timestamp);
this.name = (init.name || dfl.name );
const data = (init.data || dfl.data);
this.data = {
a: (data.a || dfl.data.a),
b: (data.b || dfl.data.b),
};
}
}
2019/3/19追記
この書き方だと、(init.hoge || dfl.hoge)の左側のinit.hogeにundefinedではないがfalseに評価される値を入れたいがデフォルト値とは異なるとき(たとえばdataの型がnumber|stringでデフォルト値が""のときに、init.dataの値が0だったとき)、意図せずデフォルト値の方が採用されてしまいます。
より正確には TypeScript 3.7 で Nullish coalescing が入ったので (init.hoge === undefined ? dfl.hoge : init.hoge)とした方が良いですが、いちいちこれを書くのは面倒なので関数化してみます。init.hoge ?? dfl.hoge と書けばよいです(2020/04/17追記、コードの修正は省略)。
const withDefault = <T>(init: Partial<T>, dfl: T) =>
<K extends keyof T>(key: K): T[K] =>
(init[key] === undefined ? dfl[key] : init[key] as T[K]);
使い方は以下のようになります。
class User implements IUser {
id: string;
timestamp: number;
name: string;
data: number[];
constructor(init: Partial<IUser> = defaultValues()) {
const wd = withDefault(init, defaultValues());
this.id = wd("id");
this.timestamp = wd("timestamp");
this.name = wd("name");
this.data = wd("data");
}
}
withDefaultはinitとデフォルト値dflをもらい、関数(key) => (init[key] === undefined ? dfl[key] : init[key])を返します。
(2020/04/17修正版)
class User implements IUser {
id: string;
timestamp: number;
name: string;
data: number[];
constructor(init: Partial<IUser> = defaultValues()) {
const dfl = defaultValues();
this.id = init.id ?? dfl.id;
this.timestamp = init.timestamp ?? dfl.timestamp;
this.name = init.name ?? dfl.name;
this.data = init.data ?? dfl.data;
}
}
メンバーにオブジェクトを持つクラスの場合はさらにwithDefaultを使います。init.dataがundefinedの可能性があるので引数1にはwd("data")を渡す必要があります。
export class User implements IUser {
id: string;
timestamp: number;
name: string;
data: { a: number, b: number };
constructor(init: IUserPartial = defaultValues()) {
const dfl = defaultValues();
const wd = withDefault(init as any, dfl);
this.id = wd("id");
this.timestamp = wd("timestamp");
this.name = wd("name");
this.data = wd("data");
const wdData = withDefault(wd("data"), dfl.data); //
this.data = {
a: wdData("a"),
b: wdData("b"),
};
}
}
init as anyにしているのはinitの型がPartial<IUser>ではなくIUserPartialになっているためです。
withDefaultを使ってkeyを渡していく方法を使うと、似た名前のメンバが複数あるときにinit.desert || dfl.dessertのようにinitとdflで別のメンバを選んでしまうミス(もし型が同じだったらコンパイルエラーも出ないので気付きにくい)も防ぐことができる点でも安心感があるかなと思います。
2019/3/26追記
上のコードだとinitializerをshallow copyしていたので、配列などの場合にちゃんとdeep copyするように修正してみます。(とはいえdeep copyを真面目に書くのは面倒そうなので、配列以外の場合はとりあえずJSON文字列化→パースでごまかしました。用途に応じて修正してください。)
export const withDefault = <T>(init: Partial<T>, dfl: T) =>
<K extends keyof T>(key: K, deepCopy: boolean = false): T[K] =>
(init[key] === undefined
? dfl[key]
: (deepCopy
? deepCopyValues( init[key] as T[K] )
: init[key] as T[K]));
export const isPrimitive = (value: any): boolean => {
switch (typeof value) {
case 'bigint':
case 'boolean':
case 'number':
case 'string':
case 'symbol':
case 'undefined':
return true;
default:
return false;
}
};
export const deepCopyValues = (source: any): any => {
if (isPrimitive(source)) return source;
if (Array.isArray(source)) {
return source.map(deepCopyValues);
}
return JSON.parse(JSON.stringify(source));
};
使い方
export class User implements IUser {
id: string;
timestamp: number;
name: string;
data: number[];
constructor(init: IUserPartial = defaultValues()) {
const wd = withDefault(init as any, defaultValues());
this.id = wd("id");
this.timestamp = wd("timestamp");
this.name = wd("name");
this.data = wd("data", true); // here
}
}