🍣

Object.entriesにasをつける人は黙って?

に公開
4

コメントも読んでください。

はじめに

JavaScriptにおいてオブジェクトのキーを列挙したいときにはObject.Keys()を使います。
値を列挙したいときにはObject.values()を使います。
キーと値を同時に列挙したいときにはObject.entries()を使います。

TypeScriptでは値の方は型情報が維持されますが、キーの方は問答無用で文字列になってしまいます。
ならばkeysentriesの返り値をasしなければならないのか。
型安全性を保つ方法はないのか。

定義を上書きしてしまえばいいじゃない

以下の型定義ファイルをプロジェクトに追加します。

// object.d.ts
type Key = string | number | symbol

declare global {
  interface ObjectConstructor {
    entries<K extends Key, V>(o: Record<K, V>): [K, V][]
    entries(o: object): [string, unknown][]
    keys<K extends Key>(o: Record<K, unknown>): K[]
    keys(o: object): string[]
    values<V>(o: Record<Key, V>): V[]
    values(o: object): unknown[]
  }
}

export {}

すると

const obj = {
  foo: "bar",
  baz: 5,
}

const entries = Object.entries(obj)
// const entries: [string, string | number][]

だったものが

const obj = {
  foo: "bar",
  baz: 5,
}

const entries = Object.entries(obj)
// const entries: ["foo" | "baz", string | number][]

になります。

幸せですね。

型定義ファイルをプロジェクトに追加する方法

プロジェクトのルートディレクトリや、既存のsrcディレクトリなど、分かりやすい場所に.d.tsファイルを作成します。例えば、src/types/my-module.d.tsのようなパスが一般的です。

// src/types/my-module.d.ts

declare module 'my-module' {
  export function myFunction(): string;
}

このファイルは、通常TypeScriptコンパイラによって自動的に認識されます。しかし、**tsconfig.jsoninclude**プロパティで明示的に指定することで、コンパイラが確実にそのファイルを含めるようにできます。

// tsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "*": ["src/*"]
    }
  },
  "include": [
    "src/**/*",
    "src/types/*.d.ts" // ここで明示的に追加する
  ]
}
declare globalを使用して型を拡張する際のメリット/デメリット

メリット

  • 既存の型の拡張: 既存のグローバルな型(例: Window, String, Array)にプロパティやメソッドを追加できます。これにより、ライブラリやアプリケーション全体で一貫した型定義を使用できます。
  • サードパーティライブラリの型定義の追加: @typesパッケージが存在しないライブラリに対して、独自の型定義を追加できます。これにより、TypeScriptの型チェックの恩恵を最大限に活用できます。

デメリット

  • グローバルな汚染: グローバルスコープに型を追加するため、意図しない型の上書きや、他のライブラリとの名前の競合が発生する可能性があります。これは、特に大規模なプロジェクトや、複数のチームが関わる場合に問題となりやすいです。
  • 保守性の低下: グローバルな型定義は、どのファイルで定義されたか追跡しにくいため、デバッグやメンテナンスが複雑になることがあります。
  • 競合の可能性: 複数のライブラリが同じグローバルな型を拡張しようとすると、競合が発生し、予期せぬ挙動を引き起こすことがあります。

declare globalは非常に強力な機能ですが、その影響範囲の広さから、本当に必要な場合にのみ使用することが推奨されます。安易な利用は、後のメンテナンスコストを増大させる可能性があるため、慎重に検討する必要があります。

実際にキーは文字列になる

例えば以下のようなオブジェクトがあったとき

const obj = {
  5: "foo",
} satisfies Record<number, string>

以下のように型推論上でキーは5ですが、実行時にキーは"5"になります。

const entries = Object.entries(obj)
// const entries: [5, string][]
// const entries = [ [ '5', 'foo' ] ]

つまり、"5"を数値として扱おうとすると実行時エラーが発生する可能性があります。
しかし、obj["5"]obj[5]はどちらも"foo"となるように、インデックスアクセスには使えます。

数値もキーとして扱えると便利なこともあるので、keysentriesで取得したキーは文字列になることを把握した上で、その利用をオブジェクトのインデックスアクセスに限定するのがよいでしょう。

おわりに

ZennのAI記事レビュー機能いいですね。積極的に使っていきたい。

Discussion

ootideaootidea

Object.keys系の関数はプロトタイプチェーンを辿らない挙動なのに対して、keyofなど型の世界ではプロトタイプチェーンを辿ってキーが列挙されます。
なので厳密に言うとその型定義はバグっていると思います(だから私はObject.entries系関数の型を改善するのは無理筋だと思っています)。
プロトタイプチェーンの有無が問題になることなんて実際のプロジェクトではほぼ無いと思いますけどね。

おかゆりぞっとおかゆりぞっと

実際にはキーが文字列になる挙動については、Template Literal Typesを使うことでおそらく対処可能だと思われます。

 declare global {
   interface ObjectConstructor {
-    entries<K extends Key, V>(o: Record<K, V>): [K, V][]
+    entries<K extends Key, V>(o: Record<K, V>): [`${K}`, V][]
     entries(o: object): [string, unknown][]
-    keys<K extends Key>(o: Record<K, unknown>): K[]
+    keys<K extends Key>(o: Record<K, unknown>): `${K}`[]
     keys(o: object): string[]
     values<V>(o: Record<Key, V>): V[]
     values(o: object): unknown[]
  }
}
const obj = {
  0: "Zero",
  [-1]: "Minus One",
} as const satisfies Record<PropertyKey, unknown>;

for (const k of Object.keys(obj)) {
  console.log(k satisfies "0" | "-1");
}

とはいえ予期せぬ不具合などあるかもしれないので少し怖いですね。このあたりの挙動を完全に把握するのはなかなか難しいです……。

hoishinhoishin

TSで不便だと思われがちなところ大体真っ当な理由があって、Object.keysはここら辺に書いてあります
https://www.mattstobbs.com/object-keys-typescript/ https://github.com/microsoft/TypeScript/issues/12870#issuecomment-266637861
プロトタイプチェーンみたいな面倒な話でなく、かなり単純な話。
要はTSの型は「実行時最低限満たしている」情報であって、完全に一致ではないので、オブジェクトのキーが実行時にTSの型以上に存在する可能性があり、TSの型上のkeyofだけに頼ると予期しない値がくるので頼ることができない。

ただもしそれは大丈夫という場合でも、declare globalするのはかなり危なっかしいので、別の名前で関数を作りなりした方がいいです。

HotariHotari

皆さんコメントありがとうございます。勉強になります