🐹

TypeScript で T 型と {...} の併用で型チェックが機能しなくなる罠

2021/04/20に公開2

はじめに

Dwango でニコニコ生放送のフロント開発を担当している misuken です。

今回は TypeScript で T 型、つまり Generics(総称型)のオブジェクト(props 等)を spread operator で {...props} した際、事実上型チェックが機能しなくなる罠の話をしたいと思います。

Generics(総称型)のオブジェクトが対象なので T 以外の U などでも条件さえ整えば発生します。
React のコードを例に挙げますが、React だけで発生する問題ではないのでご注意ください。

Generics を使わない推論の例

まずは Generics を使わないパターンで期待通りに動作する例を見ていきます。

export const Component1: VFC<{ id?: string }> = (props) => {
  return (
    <>
      1. <div {...props} />       ✅ No Error
      2. <div id={1} />            ✅ Error
      3. <div {...props} id={1} /> ✅ Error
    </>
  );
}

この場合 props の型は { id?: string | undefined } になります。
<div>idstring | undefined なので、 number にあたる 1 を渡すことになる 2 と 3 はエラーになります。

これは期待する動作です。

Generics を使う推論の例

次は Generics を使うパターンの動作を見ていきます。

// Generics を使う推論だと spread operator 部分でエラーを期待する部分がエラーにならない
// props の型は `T extends { id?: string | undefined; }`
export const Component2 = <T extends { id?: string }>(props: T) => {
  return (
    <>
      1. <div {...props} />       ✅ No Error
      2. <div id={1} />            ✅ Error
      3. <div {...props} id={1} /> 🤔 No Error
    </>
  );
}

props の型は T extends { id?: string | undefined; } になります。
T{ id?: string | undefined; } に対して代入可能な型であることが保証されています。

しかし、今回は 3 のところで <div>1 を渡せてしまっています🤔

playground で確認する

原因を突き止める

この現象の原因を突き止めるために、より正確に型がどのような変化を辿っていくのかを見られるコードを用意して調べます。

Generics を使わないパターンを分析

引数を spread operator で合成し、結果をそのまま返す関数を用意し、戻り値の型を分析してみます。

// 引数を spread operator で合成し、結果をそのまま返す関数
function F1(props: { id: string }) {
    return { ...props, id: 1 };
}

関数に通した結果をタグに渡してみると。

// `R11` の型は `{ id: number; }` になっている(`id` が上書きされているから)
// `R11` の値は `{ id: 1 }` です
const R11 = F1({ id: "" });
// この場合は正しくエラーが出る
// Type '{ id: number; }' is not assignable to type 'DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>'.
//   Type '{ id: number; }' is not assignable to type 'HTMLAttributes<HTMLDivElement>'.
//     Types of property 'id' are incompatible.
//       Type 'number' is not assignable to type 'string | undefined'.(2322)
const R12 = <div {...R11} />;

こちらは期待通り、エラーコード 2322Type 'number' is not assignable to type 'string | undefined' ( number 型は string | undefined に割り当てられません )が出ています。

Generics を使うパターンを分析

こちらも同じく、引数を spread operator で合成し、結果をそのまま返す関数を用意し、戻り値の型を分析してみます。

// Generics で推論した引数を spread operator で合成し、そのまま返す関数
function F2<T extends { id: string }>(props: T) {
    return { ...props, id: 1 };
}

関数に通した結果をタグに渡してみると。

// R21 の型は `{ id: string; } & { id: number; }` になっている(上書きされずに評価前のまま)
// R21 の値は `{ id: 1 }` です
const R21 = F2({ id: "" });
// これを spread operator でタグに渡すとエラーが出ず、 id に 1 が渡ってしまう 🤔
const R22 = <div {...R21} />;

やっぱり期待するエラーが出ていません。

さらに分析を進めるために、タグに渡さず spread operator でオブジェクトを作るだけにします。

// spread operator を使ったあとの R23 の型を見てみると... `{ id: never }`
const R23 = {...R21};
// エラーが起きない 🤔
const R24: { id: string } = R23;

playground で確認する

この現象の解説

Generics を使った型の場合、関数内では完全に評価されず { id: string; } & { id: number; } の状態で型が返ってくるようです。

これを評価すると id の値の型の部分は string & number となり、評価後の型は { id: never } になります。

never 型は any と同じく何にでも代入可能な性質を持ちます。(実際には never は永遠に存在しないので代入できないという意味であるが)

そのため、 ...props をした際に { id: string; } & { id: number; } が評価され { id: never } へ変化します。

そのオブジェクトは <div>id に対して never 型を代入するため、 stringnever を代入することになり、型エラーが出ないという流れです。

これは、タグに対する spread operator ではなくても発生するので、 JSX や React を使っていなければ大丈夫というわけでもありません。

これはバグなのか?

この挙動に対しては TypeScript の issue にいくつも報告され、 bug のラベルが付いているものもあるので、いつか解消されるかもしれません。

https://github.com/microsoft/TypeScript/issues/38469
https://github.com/microsoft/TypeScript/issues/30129
https://github.com/microsoft/TypeScript/issues/42690

ただ、この問題が解消された場合、現時点で型がガバガバになっているのに気付かず通っているところがあると、大量にエラーが発生する可能性もあるので、似たような実装に見覚えがある場合は注意が必要かもしれません。

型が通っているのに警告や実行時エラーが発生している場合はこれが原因ということもあるかもしれませんね。

まとめ

Generics の型と spread operator を併用する場合、何でも型が通る状態になっているかもしれない点に注意しましょう。

Discussion

uhyouhyo

この問題については、こちらのissueに書かれているTypeScriptチームメンバーのコメントが印象深いです。

Finally, we decided that since we'd been using intersection as a workaround for so long, that it was good enough.

https://github.com/microsoft/TypeScript/issues/10727

つまり、“バグ”とはいえ解消のためにはかなり複雑な機構が必要になるため対応されていないと見るのがいいかと思います。

misukenmisuken

コメントいただきありがとうございます。
解消するのは困難そうなので、使う側が挙動を把握して上手に付き合っていくしかなさそうですね。