
はじめに
こんにちは!PRESIDENT CARD事業部、エンジニアの Nuu です。 今回は改めてTypeScript の satisfies について掘り下げてみます。
satisfies、使っていますか? 「存在は知ってるけど、使いどころがピンとこない」「普通に型注釈書けばよくない?」——そんな人も多いのではないでしょうか。
この記事では satisfies の強みを3つの実例パターンと活用例を通して紹介します。
satisfies とは
TypeScript 4.9 で導入された演算子で、型チェックしつつ、推論結果を保持してくれます。型注釈(: T)は型チェックが効く代わりに widening が起きますが、satisfies はそのトレードオフをなくしてくれる存在です。
具体的に何が嬉しいのか、3つのパターンでsatisfiesの特性を見ていきましょう😌
パターン1:プロパティの型情報を保持 - ユニオンへの widening を防ぐ
カラーパレットを定義する例です。各色は文字列またはRGBタプルで指定できるとします。
type Colors = 'red' | 'green' | 'blue'; type RGB = [number, number, number]; const palette: Record<Colors, string | RGB> = { red: [255, 0, 0], green: '#00ff00', blue: [0, 0, 255] };
green には文字列を、red と blue にはタプルを入れています。ところが型注釈 Record<Colors, string | RGB> をつけたことで、すべてのプロパティが string | RGB に広がります。
palette.green.toUpperCase(); // ❌ Property 'toUpperCase' does not exist on type 'string | RGB'
green に "#00ff00" と書いたのに、型が string | RGB になっているため string のメソッドが使えません。
💡satisfies を使うと
satisfies はプロパティごとの型情報を保持するため、green は string、red と blue は RGB として推論されます。
const palette = { red: [255, 0, 0], green: '#00ff00', blue: [0, 0, 255] } satisfies Record<Colors, string | RGB>; palette.green.toUpperCase(); // ✅ green は string として保持される palette.red.map((v) => v / 255); // ✅ red は RGB として保持される
これは公式ドキュメントでも紹介されている satisfies の代表的なユースケースです。型注釈では各プロパティの型が string | RGB というユニオンに埋もれてしまいますが、satisfies なら実際に書いた値の型がそのまま残ります✨
パターン2:オブジェクトのキーをリテラルで保持 - string への widening を防ぐ
例として、ロールごとの権限設定を定義するケースを考えます。値を Permission[] で制限しているので、一見きちんとロールを制御できているように見えます。
type Permission = 'read' | 'write' | 'admin'; const rolePermissions: Record<string, Permission[]> = { viewer: ['read'], editor: ['read', 'write'], owner: ['read', 'write', 'admin'] };
しかし、型注釈に Record<string, Permission[]> と書いたことで、値は制限できていてもキーは string に広がってしまっているのです。つまり、keyof typeof rolePermissions が string なので、存在しないロールを渡してもコンパイルが通ってしまいます。
function getPermissions(role: keyof typeof rolePermissions) { return rolePermissions[role]; } getPermissions('viewer'); // ✅ getPermissions('hacker'); // ✅ ← 通ってしまう! ※undefined が返る
💡satisfies を使うと
satisfies は Record<string, Permission[]> を満たしているかのチェックだけを行い、rolePermissions の型自体には触りません。そのため widening が起きず、オブジェクトで定義したキーをkeyof typeof rolePermissions でリテラルのまま型に反映できます。存在しないロールを指定してしまっても、コンパイル時に弾けます✨
const rolePermissions = { viewer: ['read'], editor: ['read', 'write'], owner: ['read', 'write', 'admin'] } satisfies Record<string, Permission[]>; // keyof typeof rolePermissions は "viewer" | "editor" | "owner" になる getPermissions('hacker'); // ❌ Argument of type '"hacker"' is not assignable to type '"viewer" | "editor" | "owner"'
※ちなみに、type Role = "viewer" | "editor" | "owner" のようにキーの型を別途定義して関数の引数に使ってもこの問題は防げます。プロジェクトの方針に合わせて使い分けてみてください。
パターン3:値のリテラル型を保持 - 値の widening を防ぐ
最後は satisfies だけでは不十分なケースを紹介します。アプリの設定を定義する例を考えます。
type AppConfig = { apiBase: string; timeout: number; debug: boolean; }; const config = { apiBase: 'https://api.example.com', timeout: 3000, debug: false } satisfies AppConfig; config.apiBase; // → string("https://api.example.com" ではない) config.timeout; // → number(3000 ではない)
satisfies をつけたのに、リテラル型が保持されていません。
TypeScript はオブジェクトのキーは常にリテラルで推論しますが、値は再代入の可能性を考慮して string や number に広げて推論します。そして、satisfies は型自体に触らないので推論結果がそのまま残ります。パターン2では対象がキーなので保持されていましたが、この場合の apiBase や timeout といった値は広がったまま残るため、satisfies だけでは防げないのです。
💡as const satisfies を使うと
as const はオブジェクトのすべてのプロパティを readonly にします。readonly になると再代入ができなくなるため、TypeScript は値を string や number に広げる必要がなくなり、リテラル型のまま推論します。これに satisfies を組み合わせることで、型チェックと widening 防止を同時に実現できるのです✨
const config = { apiBase: 'https://api.example.com', timeout: 3000, debug: false } as const satisfies AppConfig; config.apiBase; // → "https://api.example.com" config.timeout; // → 3000 config.debug; // → false
satisfies の活用
switch の網羅性チェック
switch の全 case を網羅しているかのチェックも satisfies で簡潔に書けます。
全 case を網羅していれば、default 節の action は never に絞り込まれ、何も残りません。case が漏れていると、処理されなかった型が残って never に適合せず、コンパイルエラーとなります。
type Action = { type: 'increment'; amount: number } | { type: 'reset' }; function handleAction(action: Action) { switch (action.type) { case 'increment': return action.amount; case 'reset': return 0; default: return action satisfies never; } }
たとえば Action に新しい type を追加したとします。
type Action = | { type: 'increment'; amount: number } | { type: 'reset' } | { type: 'decrement'; amount: number }; // ← 追加
switch で case "decrement" を追加し忘れると、default に { type: "decrement"; amount: number } が到達して never へ適合せず、コンパイルエラーで気づけます💡
また、同様の網羅性チェックとして、default で never 型の引数を受け取り throw する関数を定義する assertNever パターンがあります。このパターンだと実行時に想定外の値が来た場合にエラーを throw してくれるため、ランタイムでも保護できます。一方、 satisfies never はコンパイル時チェックのみです。しかし、外部入力の型安全を担保しているコードベースにしていれば、satisfies never で十分かつ、変数宣言や関数定義も不要でコードをシンプルに保つことができます。
テストの fixture
実装ほどクリティカルではないですが、テストの defaultProps にも satisfies は有効です。テストのfixtureはそのファイル内で定義・消費されるので、コンポーネントにpropsが追加されても誰にも怒られずに取り残されがちです。satisfies をつけておくと、この取り残しをコンパイル時に検知できます。
// ❌ propsが増えても気づけない const defaultProps = { isLoading: false, items: [], page: 0, onPageChange: vi.fn(), hasNextPage: false }; // ✅ propsが増えたらコンパイルエラー const defaultProps = { isLoading: false, items: [], totalCount: 0, itemsPerPage: 50, page: 0, onPageChange: vi.fn(), hasNextPage: false } as const satisfies DataTableProps;
また、型注釈だと vi.fn() の型が Props の関数型に上書きされ、mockResolvedValue などの Mock メソッドが使えなくなります。satisfies なら vi.fn() の Mock 型がそのまま残るので、キャストなしで Mock メソッドを呼べます。
// 型注釈 — onPageChange は (page: number) => void になる defaultProps.onPageChange.mockResolvedValue(...); // ❌ Mock メソッドが消える // satisfies — vi.fn() の Mock 型が残る defaultProps.onPageChange.mockResolvedValue(...); // ✅
as const / satisfies / 型注釈 の比較
ここまでの3パターンで satisfies の強みを見てきました。as const や型注釈との違いも整理しておきます。
type ButtonConfig = { variant: 'primary' | 'secondary' | 'danger'; label: string; size: number; };
型注釈(: T) — 型チェック ✅ / widening 抑制 ❌
const config: ButtonConfig = { variant: 'primary', label: '送信', size: 14 }; config.variant; // → "primary" | "secondary" | "danger"(広がる) config.label; // → string config.size; // → number
satisfies — 型チェック ✅ / widening 抑制 △
const config = { variant: 'primary', label: '送信', size: 14 } satisfies ButtonConfig; config.variant; // → "primary"(ユニオン型の中ではリテラルが残る) config.label; // → string(元の型が string なので string のまま) config.size; // → number(元の型が number なので number のまま) // キーも保持される const buttons = { submit: { variant: 'primary', label: '送信', size: 14 }, cancel: { variant: 'secondary', label: 'キャンセル', size: 12 } } satisfies Record<string, ButtonConfig>; // keyof typeof buttons → "submit" | "cancel"(型注釈だと string になる)
as const — 型チェック ❌ / widening 抑制 ✅
const config = { variant: 'primary', label: '送信', size: 14 } as const; config.variant; // → "primary" config.size; // → 14(ただしタイポしてもエラーにならない)
as const satisfies — 型チェック ✅ / widening 抑制 ✅
const config = { variant: 'primary', label: '送信', size: 14 } as const satisfies ButtonConfig; config.variant; // → "primary" config.label; // → "送信" config.size; // → 14(すべてリテラル + readonly)
| 書き方 | 型チェック | widening 抑制 | リテラル型 | readonly |
|---|---|---|---|---|
| 何もなし | ❌ | ❌ | ❌ | ❌ |
: T(型注釈) |
✅ | ❌ | ❌ | ❌ |
as const |
❌ | ✅ | ✅ | ✅ |
satisfies T |
✅ | △ | △ | ❌ |
as const satisfies T |
✅ | ✅ | ✅ | ✅ |
使い分け
- 変更しない定数(設定、ルーティング、テストfixture)→
as const satisfies - 変更する値(状態の初期値、フォームのデフォルト値)→
satisfies - 推論が不要で型を固定したい → 型注釈(
: T)
AIコーディング時代こそ satisfies
型注釈は便利ですが、widening によって TypeScript の推論力を取りこぼしている場面があります。satisfies を使えば、型チェックを効かせつつ推論を活かしきることができます。
しかし、AIコーディングツールが生成するコードは、型注釈(: T)で書かれがちです。動きに問題はなく型チェックも通るので、そのままレビューを通してしまうことも少なくありませんが、そのコード、裏では wideningが起きてせっかくのTypeScriptの推論力を無駄にしてしまっています。
もちろん、型注釈が適している場面もあります。
- 関数の引数やクラスフィールドなど、そもそも
satisfiesを後置できない場所 - 再代入する変数で、宣言型をユニオンに固定して縛りたいとき(
let state: Status = 'idle') ※もっとも、再代入しないconst宣言が主流(prefer-constの普及)の現在、このケース自体は少数派
しかし、これら以外の場面——特に const でオブジェクトを定義するケースでは、satisfies を使う方が型チェックと推論の保持を両立でき、型注釈よりメリットが大きいでしょう。
人間の目ですべての widening をレビューで拾いきるのは、現実的とはいえません。AI時代だからこそ、その型注釈を satisfies に置き換えられないか一度立ち止まってみてください😌
そして、プロジェクトのルール設定に satisfies の使用を組み込んでおくことをおすすめします。仕組みでチーム全体のコード品質を守っていきましょう💪
We Are Hiring
UPSIDER Engineering Deckはこちら📣