TypeScript

satisfies演算子で型チェックと推論を両立する

読了 7分
TypeScript

satisfies演算子で型チェックと推論を両立する

設定オブジェクトを定義するとき、「型は守りたいけど、各プロパティの具体的な値の情報も残したい」という場面でよく困っていました。型注釈を付けると推論が広がってしまうし、かといって as で縛るとチェックが効かなくなる。そのあいだで困ったときのメモです。

結論から言うと、TypeScript 4.9 で入った satisfies 演算子を使えば、型に合っているかのチェックはしつつ、推論された具体的な型はそのまま残せます。最初に見たときは「これがほしかったやつだ」となりました。

まず何が問題なのか

たとえば、カラーパレットをオブジェクトで持つとします。値は文字列だったり、RGBの配列だったりするとします。

type RGB = [number, number, number]

const palette = {
  primary: '#0ea5e9',
  danger: [255, 0, 0],
}

このままだと型は推論まかせで、palette のキーをタイポしても気づけません。そこで型注釈を付けたくなります。

type Palette = Record<string, string | RGB>

const palette: Palette = {
  primary: '#0ea5e9',
  danger: [255, 0, 0],
}

これでキーと値の形はチェックされます。ですが今度は別の問題が出ます。

palette.primary.toUpperCase() // エラー:string | RGB には toUpperCase がない

palette.primary は人間には文字列だと分かっているのに、型のうえでは string | RGB に広がってしまいました。注釈を付けた瞬間、せっかくの具体的な推論が消えてしまうわけです。これが地味に不便でした。

as で縛るとチェックが効かなくなる

「じゃあ as で型を当てればいいのでは」と思って、こう書いていた時期もあります。

const palette = {
  primary: '#0ea5e9',
  danger: [255, 0, 0],
} as Record<string, string | RGB>

これは推論の問題は残りますし、そもそも as「合っていることにしてくれ」とコンパイラに伝える操作 です。チェックを通すための小細工なので、本来検出したいミスを見逃します。

const palette = {
  primary: '#0ea5e9',
  danger: [0, 0], // 本当は要素3つなのに、as だと通ってしまうことがある
} as Record<string, string | RGB>

as は最後の手段で、できれば避けたいやつです。

satisfies なら両立する

ここで satisfies の出番です。式 satisfies 型 と書くと、「この値はこの型を満たしているか」をチェックしつつ、変数の型は推論されたままにします

type RGB = [number, number, number]
type Palette = Record<string, string | RGB>

const palette = {
  primary: '#0ea5e9',
  danger: [255, 0, 0],
} satisfies Palette

palette.primary.toUpperCase() // OK:primary は string と推論されている
palette.danger[0] // OK:danger は RGB([number, number, number])

注釈のときと違って、palette.primary はちゃんと string のまま扱えます。それでいて、Palette を満たしていないとエラーになります。

const palette = {
  primary: '#0ea5e9',
  danger: [255, 0], // エラー:要素が足りない=RGB を満たさない
} satisfies Palette

「型に合っているかは確認したい。でも具体的な型情報は捨てたくない」という、まさに最初の悩みがそのまま解けました。

キーの補完も効く

satisfies のうれしい副作用として、キーの集合が推論で確定する のでキー名の補完が効きます。Record<string, ...> で注釈すると、キーは任意の文字列扱いになってしまうので、ここは差が出ます。

const routes = {
  home: '/',
  about: '/about',
  contact: '/contact',
} satisfies Record<string, string>

// home / about / contact が補完される。タイポはエラーになる
const path = routes.about

注釈版だと routes.aboutt のようなタイポでも string | undefined で素通りしがちですが、satisfies 版なら存在しないキーとして弾けます。

as const と組み合わせる

値そのものをリテラル型として固定したいときは、as const と併用すると効きます。as const で値を固定しつつ、satisfies で「想定した形に収まっているか」を確認する、という役割分担です。

const config = {
  env: 'production',
  retries: 3,
} as const satisfies {
  env: 'development' | 'production'
  retries: number
}

config.env // 型は 'production'(リテラル)のまま

env'development' | 'production' のどちらかであることはチェックしつつ、実際の値は 'production' というリテラル型で残ります。as const 単体だと型の制約はかけられないので、ここは satisfies が補ってくれます。リテラル型の話はas constの記事も合わせて読むと分かりやすいと思います。

どう使い分けるか

ざっくり、こんな整理で考えています。

  • 値を渡す前に 形だけ確認したい(推論は残したい)→ satisfies
  • 関数の引数や戻り値など、インターフェイスを固定したい → これまで通り型注釈
  • どうしても型が合わないのを承知で通したい → as(最後の手段)

設定オブジェクトやマッピングのように「具体的な値の情報が後で効いてくる」ものは satisfies、API境界のように「型を契約として固定したい」ものは注釈、という感じで使い分けるとよさそうです。

まとめ

  • 型注釈は型を固定する代わりに、推論された具体的な型を広げてしまう
  • as はチェックを素通りさせる小細工なので、本来検出したいミスを見逃す
  • satisfies型チェックを効かせつつ、推論された型をそのまま残す
  • as const satisfies 型 で「値はリテラルのまま・形は検証」も両立できる

satisfies を知ってからは、設定まわりで as を書く機会がぐっと減りました。詳しい挙動はTypeScript公式ハンドブック(The satisfies Operator)も見つつ、まずは手元の設定オブジェクトで試してみるとよさそうです。以上です。