18
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

18
Last updated at Posted at 2019-03-08

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


更新履歴

  • (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()とする
      • initundefinedのときにはデフォルト値に差し替わる
      • 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.hogeundefinedではないが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追記、コードの修正は省略)。

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

withDefaultinitとデフォルト値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.dataundefinedの可能性があるので引数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のようにinitdflで別のメンバを選んでしまうミス(もし型が同じだったらコンパイルエラーも出ないので気付きにくい)も防ぐことができる点でも安心感があるかなと思います。


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
  }
}
  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にしたいなどの状況も多く、あまり使いやすくはなかった。

18
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?