⚠️

インデックスシグネチャの罠

2025/03/10に公開

突然ですが問題です。
次のコードで最後の console.log() に渡される値は何でしょうか?

const obj = {
  a: 1,
  b: 2,
} as const

const fnc = (prop: 'a' | 'b') => ({
  ...obj,
  [prop]: 3,
})

const result = fnc('a')

console.log(result.a)

簡単ですね。 fnc'a' が渡されることで { ...obj, a: 3 } なオブジェクトが返されるわけですから、答えは 3 です。
では result.a の型はどう推論されていると思いますか?
結論からいうとこれは 1 と推論されています。 TypeScript Playground で確認してみましょう。

なんということでしょう。こんななんでもないようなコードで型とランタイムの値が異なってしまいました。
型とランタイムの値が異なるということはバグの原因になりかねません。なんとかしたいですね。
本記事ではこのような型推論がされる原因、実装の具体例、及び回避方法について説明します。

型推論の原因

上記コードで result.a1 と推論されてしまうことを確認できました。
結論からいうとこのように推論されてしまう原因は2つあります。

  1. インデックスシグネチャを持つオブジェクトをスプレッド演算子で展開すると型情報が消える
  2. 動的なプロパティ名を持つオブジェクトはインデックスシグネチャとして推論される

インデックスシグネチャの展開

これはどういうことかというと、2つのオブジェクトを { ...objA, ...objB } のように展開するとき、 objB がインデックスシグネチャの場合は objB を展開することによる型への影響がないということです。
コードを見てもらったほうがわかりやすいでしょう。

declare const objA: { a: 1; b: 2 }

declare const objB: { [x: string]: 3 }

// { a: 1; b: 2 } と推論される
const objC = {
  ...objA,
  ...objB,
}

この挙動については2018年に issue が作成され色々と議論されていますが、2025年3月現在も決着がついていません。
https://github.com/microsoft/TypeScript/issues/27273

動的なプロパティ名を持つオブジェクト

ここまでインデックスシグネチャの挙動を見てきましたが、記事冒頭のコードのどこにインデックスシグネチャがあるのでしょうか?
答えは8行目の { [prop]: 3 } の部分です。
試しに fnc() の実装から ...obj をコメントアウトして戻り値の型を確認します。

prop の型は 'a' | 'b' なので { a: number } | { b: number } と推論されて欲しいところですが、インデックスシグネチャになりました。
この挙動についても2017年に issue が作成されていますが決着はついていません。

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

実装の具体例

では具体的にどういうコードを書くときにこの問題に直面するかというと、例えばカンバンボードのような複数カラムを跨いでの要素の並び替えです。

type KanbanBoard = {
  columnA: string[]
  columnB: string[]
}

const kanbanBoard: KanbanBoard = {
  columnA: ['itemA', 'itemB'],
  columnB: ['itemC', 'itemD'],
}

const moveItem = (
  from: keyof KanbanBoard,
  to: keyof KanbanBoard,
  item: string,
): KanbanBoard => ({
  ...kanbanBoard,
  [from]: kanbanBoard[from].filter((a) => a !== item),
  [to]: [...kanbanBoard[to], item],
})

// itemA を columnA から columnB に移動
moveItem('columnA', 'columnB', 'itemA')
// {
//   columnA: ['itemB'],
//   columnB: ['itemC', 'itemD', 'itemA'],
// }

書いてみるとわかるのですが、 [from]: ...[to]: ... の部分は値に何を指定してもコンパイラは怒ってくれません。

const moveItem = (
  from: keyof KanbanBoard,
  to: keyof KanbanBoard,
  item: string,
): KanbanBoard => ({
  ...kanbanBoard,
  [from]: 'hogehoge', // 怒ってくれない!
  [to]: 'fugafuga', // 怒ってくれない!
})

解決策

実は先ほどの issue にワークアラウンドがコメントされています。

https://github.com/microsoft/TypeScript/issues/13948#issuecomment-1333159066

{ [prop]: ... } と書かずに、プロパティ名と値を受け取ってオブジェクトを返す関数を作るというやり方です。
以下抜粋です。

function kv<K extends PropertyKey, V>(k: K, v: V): { [P in K]: { [Q in P]: V } }[K] {
	return { [k]: v } as any
}

この関数を使ってさきほどの怒ってくれない例を書き直すとちゃんと怒ってくれるようになります。

 const moveItem = (
   from: keyof KanbanBoard,
   to: keyof KanbanBoard,
   item: string,
 ): KanbanBoard => ({
   ...kanbanBoard,
-  [from]: 'hogehoge', // 怒ってくれない!
+  ...kv(from, 'hogehoge'), // Type 'string' is not assignable to type 'string[]'.
 })

これは、 kv(from, 'hogehoge') の戻り値の型が { columnA: string } | { columnB: string } に推論されているためです。
あとは動的なプロパティ名を持つオブジェクトの作成を禁止すれば安心です。
eslint では no-restricted-syntax で簡単に設定できます。

eslint.config.mjs
export default [
  {
    rules: {
      'no-restricted-syntax': [
        'error',
        {
          selector: 'Property[computed=true]',
          message: '動的プロパティ名を持つオブジェクトは kv 関数で作成してください。',
        },
      ],
    },
  },
]

おわりに

インデックスシグネチャに関する直感的でない不思議な挙動について見てきました。
この記事がどなたかのお役に立てば幸いです。

Terass Tech Blog

Discussion