Object.entriesの戻り値の型を厳密にするためにNegated typesが欲しい

Object.entries() - JavaScript | MDN

Object.entries は、オブジェクトに含まれる key と value の組み合わせを配列にして返してくれる関数で、 Javascript でコードを書いてるとお世話になることも多いと思います。
しかし、これを Typescript で使おうとすると、型が微妙になってしまうという問題点があります:

type Hoge = {
    a: string;
    b: number;
};

function f(x: Hoge) {
    const entries = Object.entries(x);
    // entries の型は [string, string | number][]
    for (const [key, value] of entries) {
        // 処理
    }
}

これを解決するために、以下のような手法を使うことができます:

zenn.dev

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

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

type Hoge = {
    a: string;
    b: number;
};

function f(x: Hoge) {
    const entries = getEntries(x);
    for (const [key, value] of entries) {
        if (key === 'a') {
            console.log(value.toUpperCase());
        } else if (key === 'b') {
            console.log(value + 1);
        } else {
            // value は never
            throw new Error('should not get here.');
        }
    }
}

これを使えば厳密に型付けされた entries を使うことができてハッピー……

そう思いましたか? 実はこれ、思わぬ落とし穴があります。
Hoge の定義を type から interface に変更してみましょう:

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

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

interface Hoge {
    a: string;
    b: number;
}

function f(x: Hoge) {
    // const entries = getEntries(x);  // コンパイル通らない!
    const entries = Object.entries(x);  // これなら通るが、 entries の型は [string, any][]
    for (const [key, value] of entries) {
        // 処理
    }
}

何故か先程の getEntries ではコンパイルが通らなくなってしまいました。
また、 Object.entries の型も [string, any][] に変わってしまいました。

何故でしょう?

実はこれ、 type ではなく interface の方が望ましい挙動なのです。

www.totaltypescript.com

このライブラリの「Object.keys / Object.entries」の欄に、その説明があります。

TypeScript is a structural typing system. One of the effects of this is that TypeScript can't always guarantee that your object types don't contain excess properties:

type Func = () => {
  id: string
}

const func: Func = () => {
  return {
    id: '123',
    // No error on an excess property!
    name: 'Hello!',
  }
}

So, the only reasonable type for Object.keys to return is Array<string>.

ざっくり翻訳すると「TypeScript は構造的型付けを採用しているので、余剰のプロパティがオブジェクトに含まれていないことを保証できない。 だから Object.keys の戻り地の型で合理的なのは Array<string> のみだ」となります。

これは Object.entries に関しても同様のことが言えます:

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

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

type Hoge = {
    a: string;
    b: number;
};

function f(x: Hoge) {
    const entries = getEntries(x);
    for (const [key, value] of entries) {
        if (key === 'a') {
            console.log(value.toUpperCase());
        } else if (key === 'b') {
            console.log(value + 1);
        } else {
            // value は never
            throw new Error('should not get here.');
        }
    }
}

// 余剰のプロパティが含まれた変数
const x = { a: 'hoge', b: 42, c: { x: 13 }, };
// これは合法な呼び出し(結果として never の部分に到達して例外が投げられる)
f(x);

つまり、先ほど紹介した記事の getEntries は、危険をはらんだコードであると言えます。
この危険を回避するためには interface を使うといいので、この記事から得られる教訓として、

「TypeScript で型を定義する際は、可能なら interface を使うこと!」

が挙げられます。
こちらも参照してください:

teratail.com



さて、とはいえ、厳密な型付けが欲しいのも確かです。 どうにかならないものでしょうか?

この場合、規定のプロパティには定められた型を与えて、余剰のプロパティに対しては unknown を推論することで何とかする、というのが、おそらく最もスマートな解決策だと思います:

type Entries<T> = /* 定義をここに書く */;

// スマートな型定義ができるなら Record である必要はない
function getEntries<T extends Object>(obj: T): Entries<T> {
    return Object.entries(obj) as Entries<T>;
}

interface Hoge {
    a: string;
    b: number;
}

function f(x: Hoge) {
    const entries = getEntries(x);
    for (const [key, value] of entries) {
        if (key === 'a') {
            console.log(value.toUpperCase());
        } else if (key === 'b') {
            console.log(value + 1);
        } else {
            // value は unknown
            console.log(value);
        }
    }
}

結論から言います。 この Entries<T> は、上手く書くことができません。
というのも、現行の TypeScript では、「 string から ある特定のリテラル型を除いた型」を定義することが出来ないからです:

type T = Exclude<string, 'a' | 'b'>;
// T は string から 'a' や 'b' を除いた型…ではない。 string になる

これを解決するために、 Negated types という機能が提案されています:

github.com

実装もあるようです:

github.com

残念ながら実装の方は merge されることなく close されてしまいましたが、あると嬉しいので、いつか実装されるといいなあと思っています。

追記

参考までに、 Negated types を用いた Entries<T> の実装を書いておきます:

type Entries<T> = (
    (keyof T extends infer U
        ? U extends keyof T
            ? [U, T[U]]
            : never
        : never)
    | [string & not keyof T, unknown]
)[];