TypeScript の satisfies 演算子でハマる型推論の落とし穴

TypeScript 4.9 で導入された satisfies 演算子は、「型の検証を行いながら、推論結果を維持する」ことを目的とした革新的な構文です。
しかし実際に使ってみると、「satisfies を使ったせいで推論が思った通りに動かない」「as const と組み合わせるとリテラル型が崩れる」など、意図しない挙動にハマるケースがあります。

この記事では、satisfies の基本から、よくある型推論の落とし穴、そして安全に使うためのパターンを解説します。

satisfies とは何か

satisfies は次のような目的で使われます。

const user = {
  id: 1,
  name: 'Taro'
} satisfies { id: number; name: string }

これは次の2点を同時に満たします。

  1. オブジェクトが { id: number; name: string } 型に「適合」しているかを検証する。
  2. 変数 user の型は 推論されたまま(この場合 { id: number; name: string })で保持される。

一見便利ですが、ここに「型推論の落とし穴」が潜んでいます。

落とし穴1: satisfies は“型の narrowing”を行わない

例えば次のようなケースを考えてみましょう。

type Fruit = 'apple' | 'banana' | 'orange'

const config = {
  type: 'apple'
} satisfies { type: Fruit }

config.type // => 推論結果: "apple"

ここまでは理想的です。
しかし、この config を別のオブジェクトに展開したり、配列化したりすると挙動が変わります。

const list = [
  { type: 'apple' },
  { type: 'banana' }
] satisfies { type: Fruit }[]

list.map(item => item.type) // 推論: ("apple" | "banana")[]

ここでは一見問題ないように見えますが、次のようなパターンで混乱が起きます。

落とし穴2: as const と併用すると型が広がる/狭まらない

as const はリテラル型を固定する便利な構文ですが、satisfies と組み合わせると「リテラル型が消える」ケースがあります。

const colors = {
  primary: 'blue',
  secondary: 'red'
} as const satisfies Record<string, string>

colors.primary // 推論: string("blue" ではない!)

as const によって "blue" が固定されるはずなのに、satisfies により「Record<string, string>」の制約が優先され、型推論が広がってしまっています。
これは仕様上、satisfies が「制約チェック」だけを行い、リテラル固定の意図を推論側で破棄してしまうためです。

落とし穴3: 配列リテラルでの推論が壊れる

同様の問題は配列でも起こります。

const fruits = ['apple', 'banana'] satisfies string[]
// 推論結果: string[]

['apple', 'banana'] のようなタプル的リテラルを想定していた場合も、satisfies により単なる string[] として扱われてしまいます。
これにより、次のような enum 的使い方が破綻します。

type Fruit = typeof fruits[number]
// 想定: "apple" | "banana"
// 実際: string

つまり、satisfies型の検証には使えるが、リテラル型の抽出には向かない ということです。

解決策1: 制約チェックとリテラル推論を分離する

リテラル型を保持したい場合は、satisfiesas const を同時に使わない方が安全です。

// 型制約を別に定義する
type ColorMap = Record<string, string>

const colors = {
  primary: 'blue',
  secondary: 'red'
} as const

// 制約チェックを別途実施(IDE上や型チェック用に)
type _Check = typeof colors extends ColorMap ? true : never

この方法なら colors.primary"blue" のまま推論され、型安全性も維持されます。

解決策2: satisfies の右辺を「具体的」に書く

satisfies の右辺が広すぎる(例:Record<string, string>)と推論が壊れやすいです。
右辺をもう少し具体的に書くことで、リテラル情報が保たれます。

const colors = {
  primary: 'blue',
  secondary: 'red'
} satisfies { primary: string; secondary: string }

このように書くと、colors.primary の推論は "blue" のまま維持されます。

解決策3: satisfies を「検証用」に割り切る

結局のところ、satisfies は「推論を保ったまま型検証する」ための構文ですが、リテラル型を維持する保証はないことを理解しておく必要があります。
そのため、enum 定義や型抽出のような“静的値を利用するパターン”では、as const のみを使う方が安全です。

まとめ

satisfies は強力な型安全性を提供する一方で、型推論との相性次第で「型が広がる」「リテラルが消える」といった罠にハマることがあります。
特に以下の3点は覚えておくとよいでしょう。

  • satisfies は narrowing を行わない(リテラル型の保証はない)
  • as const と併用すると、右辺型の制約によりリテラル情報が失われることがある
  • satisfies は「検証」目的で使い、リテラル推論を期待しない

TypeScript の型システムはバージョンごとに進化しているため、プロジェクトで satisfies を使う際は、型推論結果を明示的に確認することがベストプラクティスです。

参考

タイトルとURLをコピーしました