🕌

Object.entriesの戻り値の型を厳密にする

2022/12/12に公開1

やりたいこと

const entries = Object.entries({
  a: 123,
  b: 'abc',
  c: true,
})

上記のように定義したentriesの型は[string, number | string | boolean][]と推論されます。
この推論では不都合があるので['a', number] | ['b', string] | ['c', boolean]のような型に変換したい、というのが当記事の主題です。

Object.entriesの戻り値の型にはどのような不都合があるのか?

例えば、下記のような場合はどうでしょうか?

const strKeys = ['a', 'b', 'c'] as const
const numKeys = ['d', 'e', 'f'] as const

type StrKeys = typeof strKeys[number]
type NumKeys = typeof numKeys[number]

type Obj = {
  [Key in StrKeys | NumKeys]: Key extends StrKeys ? string : number
}

const obj: Obj = {
  a: 'a', b: 'b', c: 'c',
  d: 123, e: 456, f: 789,
}

for (const [key, value] of Object.entries(obj)) {
  if (strKeys.includes(key)) { // ①-1
    // ②-1
  }
  if (numKeys.includes(key)) { // ①-2
    // ②-2
  }
}

マップ型で定義したプロパティ名によって、プロパティ値の扱いを変えたいような例です。
ここで2点問題が発生しました。

  1. keyの型はstringとして推論されるため、①の行で型エラーが発生する。
  2. valueの型はobjに設定された全プロパティ値の型のユニオンになるため、②の行で制御フロー分析が効かない。

このままでは本来やりたいことがエラーに阻まれてしまってできません。

では、なぜこのような問題が発生するのでしょうか?

Object.entriesの戻り値の型定義

TypeScriptの標準ライブラリで定義されているentriesの型を見てみましょう。

/*! *****************************************************************************
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License. You may obtain a copy of the
License at http://www.apache.org/licenses/LICENSE-2.0

THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
MERCHANTABLITY OR NON-INFRINGEMENT.

See the Apache Version 2.0 License for specific language governing permissions
and limitations under the License.
***************************************************************************** */

entries<T>(o: { [s: string]: T } | ArrayLike<T>): [string, T][];

Object.entriesが返却する配列の要素はすべて[string, T]と定義されています。
つまりプロパティの名前と値の型の組み合わせ情報は破棄されていて、プロパティ名によるプロパティ値の型の推論は効かなくなっています。

Object.entriesの戻り値の型を厳密にする

ここからが本題。Object.entriesの戻り値の型をより厳密に定義していきます。

ゴールは下記のような汎用型Entries<T>を定義することです。

type Obj = {  // 任意の型のプロパティを持つオブジェクト型
  a: string
  b: number
  c: boolean
}
type Result = Entries<Obj>
// Result = (['a', string] | ['b', number] | ['c', boolean])[]

上記の結果を得ようとして単純に下記のように定義してしまうと、プロパティ名のユニオンとプロパティ値の型のユニオンを要素として持つタプルの配列となってしまい、目的の型は得られません。

type NotGood<T> = [keyof T, T[keyof T]][]
type Result = NotGood<Obj>
// Result = ['a' | 'b' | 'c', string | number | boolean][]

これは、keyof TT[keyof T]が独立して定義されているために発生する事象です。
マップ型のようにinを使ってタプルを定義できれば楽なんですが、タプルの性質上できません。

では、keyof TT[keyof T]を関連付けて定義しましょう。

type Entries<T> = (keyof T extends infer U
  ? U extends keyof T
    ? [U, T[U]]
    : never
  : never)[]
type Result = Entries<Obj>
// Result = (['a', string] | ['b', number] | ['c', boolean])[]

これで目的の結果を得ることができました。

後はObject.entriesをラッピングした関数を定義すれば型が扱いやすくなります。
(必然的にasを使わないと行けないので若干ダサいですが……)

function getEntries<T extends Record<string, unknown>>(obj: T): Entries<T> {
  return Object.entries(obj) as Entries<T>
}

結論

Object.entriesの戻り値の型を厳密にするには下記のような型を定義する。

type Entries<T> = (keyof T extends infer U
  ? U extends keyof T
    ? [U, T[U]]
    : never
  : never)[]

Discussion

nap5nap5

type-festのEntriesを使って少しやってみました。

定義側

import { Entries } from 'type-fest'

export const toEntries = <T extends Record<string, unknown>>(data: T) => {
  return Object.entries(data) as Entries<T>
}

使用側

import { test, expect } from "vitest";
import { toEntries } from ".";
import dayjs from "dayjs";

test("toEntries", () => {
  const data = {
    a: 123,
    b: "abc",
    c: true,
    d: new RegExp(""),
    e: dayjs("2011-02-21").toDate(),
    f: [{ cowboy: "Cowboy" }, { bebop: "Bebop" }] as const,
    g: { hello: "world" },
  };
  const result = toEntries(data);
  expect(result).toStrictEqual([
    ["a", 123],
    ["b", "abc"],
    ["c", true],
    ["d", new RegExp("")],
    ["e", dayjs("2011-02-21").toDate()],
    ["f", [{ cowboy: "Cowboy" }, { bebop: "Bebop" }]],
    ["g", { hello: "world" }],
  ]);
});

demo code.

https://codesandbox.io/p/sandbox/new-violet-c42sq3?file=/src/index.test.ts:1,1

簡単ですが、以上です。