TypeScriptで初期化objectを受け取るclassの書き方メモ


クラス初期化オブジェクトを受け取るクラスを書く時の自分流パターンのメモです。


---

## 更新履歴

* (2020/04/17) 諸々更新
* (2019/03/26) ディープコピーする場合について追記
* (2019/03/19) デフォルト値とのorの取り方の改良版を追記

---


* interface `IUser` をまず用意。

```ts
interface IUser {
  id:        string;
  timestamp: number;
  name:      string;
  data:      number[];
}
```

* 初期化子`init`が無いときのためのデフォルト値を作成
    * ただの定数ではなく関数にしているのは、毎回新たなオブジェクトを返させることによりデフォルト値オブジェクトの値が書き換えられる心配を無くすため。
        * 例えば`data`のようなprimitiveではない値はconstructor内でdeep copyしていないと参照のコピーになるため後から書き換えられる可能性がある。
        * 2回以上このconstructorが実行されるときや、constructor以外でこのデフォルト値を使うところがあるとき、定数のつもりで使っているデフォルト値の中身が書き換わっていると変なことになる。
        * デフォルト値を`Object.freeze`などで書き換えられないようにする手もありそうだが、そのままだと浅いところまでしか固定してくれないし、結局constructor内でprimitive以外のディープコピーが必要になるだけなので、ここではあまりうれしくなかった（良い方法があれば教えてください）。毎回新たにデフォルト値オブジェクトを返してくれる関数を作ってしまうのが間違いが無くて簡単だと思う。

```ts
const defaultValues = (): IUser => ({
  id        : '',
  timestamp : Date.now(),
  name      : '',
  data      : [],
});
```

（2020/04/17追記）

```ts


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]



```ts
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`を以下のように作ればよさそうです。


```ts

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`だったとき）、意図せずデフォルト値の方が採用されてしまいます。
~~より正確には`(init.hoge === undefined ? dfl.hoge : init.hoge)`とした方が良いですが、いちいちこれを書くのは面倒なので関数化してみます。~~ TypeScript 3.7 で Nullish coalescing が入ったので `init.hoge ?? dfl.hoge` と書けばよいです（2020/04/17追記、コードの修正は省略）。

```ts
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]);
```

使い方は以下のようになります。

```ts
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修正版）

```ts
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")`を渡す必要があります。

```ts

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文字列化→パースでごまかしました。用途に応じて修正してください。）

```ts
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));
};
```

使い方


```ts

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
  }
}
```



[^1]: `const objkeys = (<T extends object, K extends keyof T>(obj: T): K[] => Object.keys(obj) as K[])`のように`Object.keys`の型を真面目に書いてもダメだった。何かtslintに怒られない安全で楽な初期化方法があれば知りたい。
[^2]: 再帰的にPartialを適用する`DeepPartial`型を作ってみたりはしたものの、ちょうど2段階までpartialにしたいなどの状況も多く、あまり使いやすくはなかった。