TypeScript satisfies演算子完全ガイド - 型安全とリテラル推論の両立


はじめに

TypeScript 4.9で導入されたsatisfies演算子は、型安全性とリテラル推論を両立する画期的な機能です。

従来のas型アサーションや型注釈では解決できなかった問題を、エレガントに解決します。

satisfiesが解決する問題

// 従来の問題: 型注釈を使うとリテラル型が失われる
type Color = "red" | "green" | "blue" | { r: number; g: number; b: number }

const palette: Record<string, Color> = {
  primary: "red",      // string型に広がる("red"リテラルが失われる)
  secondary: { r: 0, g: 255, b: 0 },
}

palette.primary.toUpperCase() // エラーにならない(stringと推論されるため)

// satisfiesを使った解決
const palette = {
  primary: "red",      // "red"リテラル型が保持される
  secondary: { r: 0, g: 255, b: 0 },
} satisfies Record<string, Color>

palette.primary.toUpperCase() // ✓ 正しく動作("red"型)
palette.primary = "yellow"    // ✗ エラー("red"型に"yellow"は代入不可)

この記事では、satisfiesの仕組みと実践的な活用方法を深掘りします。

satisfies演算子の基本

構文

const value = expression satisfies Type

satisfiesは、式が指定した型を満たすことを検証しつつ、式の元の型を保持します。

as型アサーションとの違い

type User = {
  name: string
  age: number
}

// as型アサーション: 型チェックを上書き(危険)
const user1 = { name: "Alice" } as User
// ✗ ageがなくてもエラーにならない(実行時エラーの原因)

// satisfies演算子: 型チェックを実行(安全)
const user2 = { name: "Alice" } satisfies User
// ✓ エラー: Property 'age' is missing

// 正しい例
const user3 = { name: "Alice", age: 30 } satisfies User
// ✓ 型チェックOK & リテラル型保持

型注釈との違い

// 型注釈: 型が広がる
const config: Record<string, string> = {
  apiUrl: "https://api.example.com",
  wsUrl: "wss://ws.example.com",
}

config.apiUrl // string型(リテラルが失われる)

// satisfies: リテラル型が保持される
const config = {
  apiUrl: "https://api.example.com",
  wsUrl: "wss://ws.example.com",
} satisfies Record<string, string>

config.apiUrl // "https://api.example.com" 型(リテラル型保持)

実践的なユースケース

1. ルーティング定義

// ルート定義の型
type Route = {
  path: string
  component: string
  meta?: {
    requiresAuth?: boolean
    title?: string
  }
}

// 従来の方法(型注釈)
const routes: Record<string, Route> = {
  home: {
    path: "/",
    component: "Home",
  },
  dashboard: {
    path: "/dashboard",
    component: "Dashboard",
    meta: { requiresAuth: true },
  },
}

routes.home.path // string型(リテラルが失われる)
routes.unknownRoute // エラーにならない(Record型のため)

// satisfiesを使った改善
const routes = {
  home: {
    path: "/",
    component: "Home",
  },
  dashboard: {
    path: "/dashboard",
    component: "Dashboard",
    meta: { requiresAuth: true },
  },
} satisfies Record<string, Route>

routes.home.path // "/" 型(リテラル型保持)
routes.unknownRoute // ✗ エラー(存在しないプロパティ)

// 型安全なルート参照
function navigateTo(routeName: keyof typeof routes) {
  const route = routes[routeName]
  console.log(`Navigating to ${route.path}`)
}

navigateTo("home")      // ✓ OK
navigateTo("unknown")   // ✗ エラー

2. 環境変数の検証

type Environment = "development" | "staging" | "production"

type Config = {
  env: Environment
  apiUrl: string
  features: {
    analytics: boolean
    betaFeatures: boolean
  }
}

// 設定の定義(型チェック + リテラル保持)
const config = {
  env: "production",
  apiUrl: "https://api.example.com",
  features: {
    analytics: true,
    betaFeatures: false,
  },
} satisfies Config

// リテラル型が保持されるため、型推論が正確
if (config.env === "production") {
  // ✓ 正確な型推論
}

config.env = "development" // ✗ エラー("production"リテラルに代入不可)

// ユニオン型で柔軟性を持たせる
type Config2 = {
  env: Environment
  apiUrl: string
  debug?: boolean
}

const config2 = {
  env: "development" as const,
  apiUrl: "http://localhost:3000",
  debug: true,
} satisfies Config2

config2.env = "staging" // ✗ エラー("development"リテラル)

3. APIレスポンスの型検証

type ApiResponse<T> = {
  data: T
  status: "success" | "error"
  message?: string
}

type User = {
  id: number
  name: string
  email: string
}

// APIレスポンスのモック
const mockResponse = {
  data: {
    id: 1,
    name: "Alice",
    email: "alice@example.com",
  },
  status: "success",
} satisfies ApiResponse<User>

mockResponse.status // "success" 型(リテラル保持)
mockResponse.data.id // number型(リテラル化されない)

// エラーケースのテスト
const errorResponse = {
  data: null,
  status: "error",
  message: "User not found",
} satisfies ApiResponse<User | null>

// 型チェックが厳密に機能
const invalidResponse = {
  data: { id: 1, name: "Alice" }, // ✗ emailがない
  status: "success",
} satisfies ApiResponse<User> // エラー

4. イベントハンドラーマップ

type EventMap = {
  click: (x: number, y: number) => void
  scroll: (offset: number) => void
  keydown: (key: string, modifiers: string[]) => void
}

// イベントハンドラーの定義
const handlers = {
  click: (x, y) => console.log(`Clicked at (${x}, ${y})`),
  scroll: (offset) => console.log(`Scrolled to ${offset}`),
  keydown: (key, modifiers) => console.log(`Key: ${key}, Modifiers: ${modifiers}`),
} satisfies EventMap

// 型安全なイベント発火
function emit<K extends keyof typeof handlers>(
  event: K,
  ...args: Parameters<typeof handlers[K]>
) {
  handlers[event](...args)
}

emit("click", 10, 20)           // ✓ OK
emit("scroll", 100)             // ✓ OK
emit("keydown", "Enter", [])    // ✓ OK
emit("click", "invalid")        // ✗ エラー(引数の型が違う)
emit("unknown", 1, 2)           // ✗ エラー(存在しないイベント)

5. テーマ設定

type ColorValue = string | { light: string; dark: string }

type Theme = {
  colors: Record<string, ColorValue>
  spacing: Record<string, number>
}

// テーマ定義
const theme = {
  colors: {
    primary: "#007bff",
    secondary: { light: "#6c757d", dark: "#adb5bd" },
    success: "#28a745",
  },
  spacing: {
    small: 8,
    medium: 16,
    large: 24,
  },
} satisfies Theme

// リテラル型が保持される
theme.colors.primary // "#007bff" 型
theme.spacing.small  // 8 型

// 型安全なテーマアクセス
function getColor(name: keyof typeof theme.colors): ColorValue {
  return theme.colors[name]
}

getColor("primary")   // ✓ OK
getColor("unknown")   // ✗ エラー

// ダークモード対応の型安全な関数
function resolveColor(name: keyof typeof theme.colors, mode: "light" | "dark"): string {
  const color = theme.colors[name]
  if (typeof color === "string") return color
  return color[mode]
}

高度な使用例

ジェネリック関数との組み合わせ

// APIエンドポイント定義
type Endpoint<T> = {
  url: string
  method: "GET" | "POST" | "PUT" | "DELETE"
  response: T
}

type Endpoints = {
  getUser: Endpoint<{ id: number; name: string }>
  createUser: Endpoint<{ id: number }>
  deleteUser: Endpoint<{ success: boolean }>
}

// エンドポイント定義(型チェック + 推論)
const endpoints = {
  getUser: {
    url: "/api/users/:id",
    method: "GET",
    response: {} as { id: number; name: string },
  },
  createUser: {
    url: "/api/users",
    method: "POST",
    response: {} as { id: number },
  },
  deleteUser: {
    url: "/api/users/:id",
    method: "DELETE",
    response: {} as { success: boolean },
  },
} satisfies Endpoints

// 型安全なAPIクライアント
async function callApi<K extends keyof typeof endpoints>(
  endpoint: K,
  params?: Record<string, string>
): Promise<typeof endpoints[K]["response"]> {
  const config = endpoints[endpoint]
  const url = config.url.replace(/:(\w+)/g, (_, key) => params?.[key] ?? "")

  const response = await fetch(url, { method: config.method })
  return response.json()
}

// 使用例
const user = await callApi("getUser", { id: "123" })
user.name // string型(正確に推論される)

const created = await callApi("createUser")
created.id // number型

条件型との組み合わせ

type ValueOrGetter<T> = T | (() => T)

type Config<T> = {
  [K in keyof T]: ValueOrGetter<T[K]>
}

type AppConfig = {
  apiUrl: string
  timeout: number
  retries: number
}

// 設定定義
const config = {
  apiUrl: "https://api.example.com",
  timeout: () => (process.env.NODE_ENV === "production" ? 5000 : 10000),
  retries: 3,
} satisfies Config<AppConfig>

// 値の解決関数
function resolveValue<T>(value: ValueOrGetter<T>): T {
  return typeof value === "function" ? (value as () => T)() : value
}

const apiUrl = resolveValue(config.apiUrl)     // string型
const timeout = resolveValue(config.timeout)   // number型

バリデーションスキーマ

type Validator<T> = (value: unknown) => value is T

type Schema<T> = {
  [K in keyof T]: Validator<T[K]>
}

type User = {
  id: number
  name: string
  email: string
  age?: number
}

// バリデータ定義
const isNumber = (value: unknown): value is number => typeof value === "number"
const isString = (value: unknown): value is string => typeof value === "string"

const userSchema = {
  id: isNumber,
  name: isString,
  email: isString,
  age: (value: unknown): value is number | undefined =>
    value === undefined || typeof value === "number",
} satisfies Schema<User>

// 型安全なバリデーション
function validate<T>(data: unknown, schema: Schema<T>): data is T {
  if (typeof data !== "object" || data === null) return false

  for (const key in schema) {
    const validator = schema[key]
    const value = (data as Record<string, unknown>)[key]
    if (!validator(value)) return false
  }

  return true
}

// 使用例
const data: unknown = { id: 1, name: "Alice", email: "alice@example.com" }

if (validate(data, userSchema)) {
  // この中では data は User 型として扱える
  console.log(data.name.toUpperCase())
}

satisfiesのベストプラクティス

1. 設定ファイルでの使用

// ✓ Good: 型チェック + リテラル保持
const dbConfig = {
  host: "localhost",
  port: 5432,
  database: "myapp",
} satisfies Record<string, string | number>

// ✗ Bad: 型が広がる
const dbConfig: Record<string, string | number> = {
  host: "localhost",
  port: 5432,
  database: "myapp",
}

2. 定数の型検証

// ✓ Good: 厳密な型チェック
const HTTP_STATUS = {
  OK: 200,
  CREATED: 201,
  BAD_REQUEST: 400,
  NOT_FOUND: 404,
} satisfies Record<string, number>

// 自動補完が効く
HTTP_STATUS.OK // 200

3. ユニオン型の検証

type Status = "pending" | "approved" | "rejected"

// ✓ Good: すべてのステータスが定義されているか検証
const statusMessages = {
  pending: "処理中",
  approved: "承認済み",
  rejected: "却下",
} satisfies Record<Status, string>

// もし1つでも欠けているとエラー
const incomplete = {
  pending: "処理中",
  approved: "承認済み",
  // rejected がない
} satisfies Record<Status, string> // ✗ エラー

まとめ

satisfies演算子は、TypeScriptの型システムをより柔軟かつ安全に使うための強力なツールです。

主な利点

  • 型安全性の向上 - 型チェックを実行しつつ、型情報を保持
  • リテラル型の保持 - 自動補完と型推論が正確に
  • asアサーションより安全 - 実行時エラーを防ぐ

使い分けガイド

シーン推奨
設定オブジェクトの定義satisfies
ルーティング・イベントマップsatisfies
型の上書き(危険)as(非推奨)
変数の型宣言(型が広がってもOK)型注釈 : Type

導入チェックリスト

  • TypeScript 4.9以上にアップグレード
  • 設定オブジェクトでsatisfiesを使用
  • asアサーションをsatisfiesに置き換え
  • ESLintでconsistent-type-assertionsルールを設定

satisfiesを活用すれば、型安全性と開発体験を両立した、より堅牢なTypeScriptコードが書けます。