JavaScript/TypeScriptメモ

TypeScriptでオブジェクトの全プロパティを走査するときの注意点

TypeScriptでオブジェクトの全プロパティを走査するときの注意点

TypeScriptで、オブジェクトの全てのプロパティの走査をするのは意外と大変です。

たとえば、次のような処理をしたいときには、気を付けて書かないと、TypeScriptの型がうまく付かずに、エラーが大量に発生してしまいます。

  • 全プロパティを読み込む
  • 全プロパティを別オブジェクトにコピーする
  • 全プロパティを別オブジェクトにコピーするときに、一部プロパティを改変する

そこで、このような処理を書くときに気を付けるべき点を書いていきます。

全プロパティを読み込む

JavaScriptで、オブジェクトの全プロパティの一覧を取得するためにはfor ~ in構文や、Object.keys()構文が使われます。

ただ、どちらも、TypeScriptでは、気持ちよく使えません。

for ~ in構文を使うと、プロパティアクセス時にエラーが発生する

for ~ in構文を使うと、const value = obj[key]の行でエラーが発生します。

const obj = {
  a: 1,
  b: 'b',
  c: true,
};

for (const key in obj) {
  const value = obj[key]; //エラー}

具体的には、次の2つのエラーが発生します。

  • Unsafe assignment of an any value.
  • Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ a: number; b: string; c: boolean; }'. No index signature with a parameter of type 'string' was found on type '{ a: number; b: string; c: boolean; }'.

Object.keys()構文でも、プロパティアクセス時にエラーが発生する

同様に、Object.keys()構文を使っても、const value = obj[key]の行でエラーが発生します。

const obj = {
  a: 1,
  b: 'b',
  c: true,
};

const keys = Object.keys(obj);

keys.forEach((key) => {
  const value = obj[key]; //エラー});

これでも、先ほどとまったく同じエラーが表示されます。

  • Unsafe assignment of an any value.
  • Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ a: number; b: string; c: boolean; }'. No index signature with a parameter of type 'string' was found on type '{ a: number; b: string; c: boolean; }'.

for ~ in、Object.keys() 構文でエラーが発生する原因

TypeScriptでは、objオブジェクトのプロパティとして指定する値は「keyof typeof obj」型(=「 a | b | c 」型)でないとエラーが発生してしまいます。

ところが、今回のプログラムでは、次のように、どちらも、変数keyは「string」型になってしまうため、エラーが発生してしまうのです。

  • for ~ in構文で得られる、変数keyは「string」型になる
  • Object.keys()構文で得られる、変数keysは「string[]」型になるため、変数keyは「string」型になる

変数keyが「string」型になってしまうと、変数keyに、「a」「b」「c」以外の値が入る可能性が否定できないため、エラーになってしまうのです。

また、「a」「b」「c」以外のプロパティを指定した場合、その結果の値の型がどうなるかTypeScriptとして保証できないため、valueは、自動的に「any」型になってしまうのです

実際、下記を見てみると、次のようなことが書かれています。

  • Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ a: number; b: string; c: boolean; }'. No index signature with a parameter of type 'string' was found on type '{ a: number; b: string; c: boolean; }'.
    • 「string」型の値をindexとして指定することはできない
    • 「string」型の値をindexとして指定した場合、その評価結果は「any」型になる

その結果、「any」型の値を左辺(value)に代入しようとしているため、次のエラーが発生してしまいます。

  • Unsafe assignment of an any value.

なぜ、for ~ in、Object.keys() で「keyof typeof obj」型が得られないのか?

直感的には、for ~ in構文で「keyof typeof obj」型、Object.keys()構文で「(keyof typeof obj)[]」型の値が得られても良さそうなところです。

それなのに、このような仕様になっている理由は、これらの構文で、指定したキー以外の値が入る可能性があるからです

たとえば、次のプログラムでは、関数objFunc内の変数keyには「a」「b」だけでなく「c」が入る可能性があります

type Obj = {
  a: () => void;
  b: () => void;
};

const objFunc = (argobj: Obj): void => {
  // key には「a」「b」「c」のいずれかが入る(=keyが「 a | b 」型にならない!)
  for (const key in argobj) {

    // keyが「c」のときには「no function」という文字列データが格納される
    const func = argobj[key];

    // keyが「c」のときは、文字列が格納されているためエラーになる!    func();  }
};

const main = (): void => {
  const obj = {
    a: () => { console.log('a'); },
    b: () => { console.log('b'); },
    c: 'no function', //余計なプロパティが入っている  };

  objFunc(obj); //余計なプロパティが入っていてもエラーにならない};

main();

Object.keys()構文を使う場合も、話はまったく同じです。

要するに、型で指定したプロパティ以外の値が、変数「key」、変数「keys」に入る可能性があるため、型が「string」や「string[]」になってしまうのです。

解決法

解決法として、次のようなものがあり得ます。

状況に合わせて、適したものを使ってください。

解決法1: 変数keyの値をチェックする

ループ内で変数keyの値をチェックする方法は、一番シンプルです。

const obj = {
  a: 1,
  b: 'b',
  c: true,
};

for (const key in obj) {
  if (key === 'a' || key === 'b' || key === 'c') {    //変数keyの値は「a」「b」「c」のいずれかであることが保証されているのでエラーが出ない
    const value = obj[key];
  }}

ループ内で、変数keyが「a」「b」「c」のいずれかであることを確認しておけば、objのプロパティであることが保証されますので、エラーが出なくなります。

この方法であれば、asなどを使って、無理やり型を補正しないで済みます。

とはいえ、いちいち、プロパティを明示的に列挙するのでは、for ~ in構文を使うメリットが半減してしまいます。

また、変数「obj[key]」の型は、あり得る型すべてのUnionになることに注意してください。

今回の例であれば、変数fromValueの型は「 string | number | boolean 」になります。

解決法2: Object.entries()を使う

const obj = {
  a: 1,
  b: 'b',
  c: true,
};

Object.entries(obj).forEach(([key, fromValue]) => {  const value = fromValue;
});

Object.Entries()を使うと、プロパティアクセスをすることなく値が取得できるため、エラーは出なくなります。

先ほどと同じく、変数「fromValue」の型は、あり得る型すべてのUnion「 string | number | boolean 」になります。

解決法3: 変数keyを無理やり「keyof typeof obj」型にする

const obj = {
  a: 1,
  b: 'b',
  c: true,
};

// 変数「key」を「keyof typeof obj」型として宣言する。// 変数「key」に、この型に適した値しか入らないことを、プログラマが保証する必要があることに注意let key: keyof typeof obj;
for (key in obj) {
  const value = obj[key];
}

変数「key」を「keyof typeof obj」型として宣言すると、エラーは出なくなります。

ただし、先ほどの「なぜ、for ~ in、Object.keys() で「keyof typeof obj」型が得られないのか?」で書いた例のように、TypeScriptコンパイラは、変数keyに「keyof typeof obj」型を満たす値だけが入ることを保証してくれません

変数「key」の中身が問題ないかどうかは、プログラムを組む人が責任をもって管理する必要があることに、注意してください。

解決法4: value参照時にkeyの型を無理やり「keyof typeof obj」型にする

const obj = {
  a: 1,
  b: 'b',
  c: true,
};

for (const key in obj) {
  // 変数「key」を「keyof typeof obj」型に読み替える  // 変数「key」に、この型に適した値しか入らないことを、プログラマが保証する必要があることに注意  const value = obj[key as keyof typeof obj];}

今度は、変数「key」を参照するときに、「keyof typeof obj」型に読み替えています。これでも、エラーは出なくなります。

注意点は、先ほどの「解決法3」と同じです。

TypeScriptコンパイラは、変数keyに「keyof typeof obj」型を満たす値だけが入ることを保証してくれません。変数「key」の中身はプログラムを組む人が責任をもって管理する必要があります。

解決法5: 型変数を使ったfunctionに分離する

const getProps = <T extends Record<string, unknown>>(obj: T): void => {  for (const key in obj) {  // 「key」は「Extract<keyof T, string>」型    const value = obj[key]; // 問題なし。  }};  
const main = () => {
  const obj = {
    a: 1,
    b: 'b',
    c: true,
  };

  getProps(obj);
}

main();

コピーロジックを別functionに分離したうえで、オブジェクトを引数で受け取るとエラーは発生しません。

この場合、型変数Tの型はobjの構造と完全に一致するため、変数「key」が「string」型にならない、、、ということだろうと想像しています。

全プロパティを別オブジェクトにコピーする

スプレッド構文を使うのがおすすめ

全プロパティを別オブジェクトにコピーしたいときには、特別な事情がない限りは、スプレッド構文を使うようにしましょう。

これなら、特に問題は発生しません。

const obj = {
  a: 1,
  b: 'b',
  c: true,
};

const obj2 = { ...obj };

コピーするfunctionを作ると良い場合も

あるいは、コピーするロジックを別functionに分離したうえで、オブジェクトを引数で受け取ってもエラーは発生しません。

const copyProps = <T extends Record<string, unknown>>(obj: T): T => {
  const ret = {} as T;

  for (const key in obj) {
    ret[key] = obj[key]; // 問題なし。「key」は「Extract<keyof T, string>」型
  }

  return ret;
};

考え方は、先ほどの「解決法5」とまったく同じです。

何らかの事情で、個々のプロパティを転記せざるを得ない場合

もし、何らかの事情で、個々のプロパティを転記せざるを得ない場合には、かなり大変です。

変数「key」の型が「keyof typeof obj2」型でない

とりあえず、変数keyを参照時に「keyof typeof obj」型に変換する方法で書いてみると、次のようにエラーが発生します。

const obj = {
  a: 1,
  b: 'b',
  c: true,
};

//後で、各プロパティを代入できるように、obj2を「typeof obj」型として定義
const obj2 = {} as typeof obj;

for (const key in obj) {
  obj2[key] = obj[key as keyof typeof obj];//~~~~~~~~~
//Element implicitly has an 'any' type because expression of type 'string'
//can't be used to index type '{ a: number; b: string; c: boolean; }'.
//No index signature with a parameter of type 'string' was found on type
//'{ a: number; b: string; c: boolean; }'.
}

変数keyの型が、「keyof typeof obj2」型ではなく「string」型であることから、左辺のobj2[key]の部分でエラーが発生しています。

「obj2[key]」の型が「never」型になる

そこで、obj2[key]の「key」を、無理やり「keyof typeof obj2」型にしてみます。

const obj = {
  a: 1,
  b: 'b',
  c: true,
};

//後で、各プロパティを代入できるように、obj2を「typeof obj」型として定義
const obj2 = {} as typeof obj;

for (const key in obj) {
  obj2[key as keyof typeof obj2] = obj[key as keyof typeof obj];//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//Type 'string | number | boolean' is not assignable to type 'never'.
//Type 'string' is not assignable to type 'never'.
}

すると、今度は、左辺の「obj2[key]」が、never型になってしまい、代入することができません。

変数keyを「keyof typeof obj2」と読み替えると、その取り得る値は、「a」「b」「c」の3つです。

すると、「obj2[key]」の型は「string」「number」「boolean」のいずれかということになります。

この場合、代入できる値の型は、「obj2[key]」が3つの型のどれであっても矛盾しないような値だけです。つまり、「string & number & boolean」を満たすような型だけを代入することができます。

ところが、このような値はありませんので、左辺は、never型になってしまうのです。

データ型ごとに個別に代入すれば問題はないが、、、

左辺の型が確定しないのが問題なのですから、次のようにデータ型ごとに代入をすればうまくいきます。

const obj = {
  a1: 1,
  a2: 2,
  b1: 'b',
  b2: 'bb',
  c1: true,
  c2: false,
};

const obj2 = {} as typeof obj;

for (const key in obj) {
  if (key === 'a1' || key === 'a2') {    //左辺はnumber型、右辺もnumber型
    obj2[key] = obj[key];
  } else if (key === 'b1' || key === 'b2') {    //左辺はstring型、右辺もstring型
    obj2[key] = obj[key];
  } else if (key === 'c1' || key === 'c2') {    //左辺はboolean型、右辺もboolean型
    obj2[key] = obj[key];
  }}

もっとも、ここまでやるなら、for ~ in構文を使わずに、個別に代入してしまうほうがマシなくらいかもしれません。

「obj2」を「any」型にする

そこで、最後の手段として「変数obj2」型を「any」型にしましょう。

const obj = {
  a: 1,
  b: 'b',
  c: true,
};

//後で、各プロパティを代入できるように、obj2を「typeof obj」型として定義
const obj2 = {} as typeof obj;

for (const key in obj) {
  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access  (obj2 as any)[key] = obj[key as keyof typeof obj];}

これで、TypeScriptとしてはエラーはなくなりました。

ただし、私の手元の設定では、any型のプロパティアクセスをしているために、ESLintでエラーが発生します。そこで、ESLintのエラーを消すコメントを追加しています。

また、変数obj2をany型として扱っているため、TypeScriptの型チェックは無効化されています。適切に「typeof obj」型になっていることは、プログラマの責任で確認する必要があることに注意してください。

全プロパティを別オブジェクトにコピーするときに、一部プロパティを改変する

たとえば、あるオブジェクトをコピーするけれど、プロパティが数値のときだけはその数値を文字列に変換するような関数を考えてみます。

このとき、特定のオブジェクトを変換するだけではなく、次のように、どんなオブジェクトでも数値から文字列に変換できるような汎用的な関数を作ってみようと思います。

  • {a: 1, b: 'b', c: true }{a: '1', b: 'b', c: true }
  • {b: 10, c: 'c' }{b: '10', c: 'c' }

今回は、単純化のため、オブジェクトのネストはないケースで考えてみたいと思います。

//プロパティがnumber型の場合のみstring型に変換する型
type ConvertNumberToString<T> = {
  [K in keyof T]: T[K] extends number ? string : ConvertNumberToString<T[K]>;
};

const convertNumberToString = <T extends Record<string, unknown>>(
  obj: T
): ConvertNumberToString<T> => {
  const ret = {} as ConvertNumberToString<T>;

  for (const key in obj) {
    if (typeof obj[key] === 'number') {
      // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
      (ret as any)[key] = (obj[key] as number).toString();
    } else {
      //オブジェクトのネストはない前提
      // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
      (ret as any)[key] = obj[key];
    }
  }

  return ret;
};

const main = () => {
  const obj = {
    a: 1,
    b: 'b',
    c: true,
  };

  const obj2 = convertNumberToString(obj);

  console.log(obj);
  // { a: 1, b: 'b', c: true }

  console.log(obj2);
  // { a: '1', b: 'b', c: true }
};

main();

考え方自体は、先ほどの《「obj2」を「any」型にする》例とまったく同じです。

個々のプロパティごとに、データ型を判定して適切な値を代入していきます。

「as」を各所で使用していますが、(たぶん)やむを得ないのだろうと思います。

まとめ

オブジェクトの全プロパティを走査するようなプログラムをTypeScriptで書くと、非常に面倒なことが多いので気を付けましょう。