UPSIDER Tech Blog

型注釈で損しない! 実例3パターンで学ぶsatisfiesの特性と活用

はじめに

こんにちは!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 には文字列を、redblue にはタプルを入れています。ところが型注釈 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 はプロパティごとの型情報を保持するため、greenstringredblueRGB として推論されます。

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 rolePermissionsstring なので、存在しないロールを渡してもコンパイルが通ってしまいます。

function getPermissions(role: keyof typeof rolePermissions) {
  return rolePermissions[role];
}

getPermissions('viewer'); // ✅
getPermissions('hacker'); // ✅ ← 通ってしまう! ※undefined が返る

💡satisfies を使うと

satisfiesRecord<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 はオブジェクトのキーは常にリテラルで推論しますが、値は再代入の可能性を考慮して stringnumber に広げて推論します。そして、satisfies は型自体に触らないので推論結果がそのまま残ります。パターン2では対象がキーなので保持されていましたが、この場合の apiBasetimeout といった値は広がったまま残るため、satisfies だけでは防げないのです。

💡as const satisfies を使うと

as const はオブジェクトのすべてのプロパティを readonly にします。readonly になると再代入ができなくなるため、TypeScript は値を stringnumber に広げる必要がなくなり、リテラル型のまま推論します。これに 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 節の actionnever に絞り込まれ、何も残りません。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

herp.careers

herp.careers

UPSIDER Engineering Deckはこちら📣

speakerdeck.com