型に対してもsatisfiesしたい! TypeScriptで型の構造を強制する方法
はじめに
TypeScript の discriminated union(判別可能なユニオン型)は便利です。
共通の type プロパティなどでユニオンのメンバーを判別できるようにしておくと、型の絞り込み(narrowing)が効いて、条件分岐の中で特定の型として扱えます。
discriminated unionを使って複数の型を定義するとき、各ユニオンメンバーが必ず同じプロパティを持つように、型の構造を強制したいと思ったことはありませんか?
例えば、通知システムを考えてみます。メール通知、Slack 通知、SMS 通知など、複数の通知手段があり、それぞれを discriminated union で表現します。
type EmailNotification = {
type: "email";
message: string;
address: string;
};
type SlackNotification = {
type: "slack";
message: string;
channelId: string;
};
type SmsNotification = {
type: "sms";
message: string;
phoneNumber: string;
};
type Notification = EmailNotification | SlackNotification | SmsNotification;
ここには暗黙のルールがあります。「すべての通知型は type と message というプロパティを持つ」というルールです。
しかし、このルールはコード上で明示されていません。
そうなると、別の開発者が新しい型を追加するとき、以下のようになるかもしれません。
type PushNotification = {
type: "push";
content: string; // messageではなくcontentになってしまった
deviceToken: string;
};
プロパティ名が message ではなく content になってしまいました。こうなると例えば共通処理が書きづらくなります。
function getNotificationMessage(notification: Notification): string {
// ❌ PushNotificationにはmessageがないのでエラー
return notification.message;
}
// 仕方なく条件分岐を追加すると、コードが複雑になっていく
function getNotificationContent(notification: Notification): string {
if (notification.type === "push") {
return notification.content;
}
return notification.message;
}
この例はシンプルなので問題が起きにくいかもしれませんが、実際の開発ではプロパティがもっと多かったり、入れ子になっていたりと複雑な構造になることがあります。
そうなると最初に開発した人の意図が伝わらず、いつのまにか当初の意図に反した型が定義されてしまう可能性があります。
その結果、上記のような条件分岐がコードベースのあちこちに増えていきます。
また、新しくコードを読む開発者は「この型は message だけど、あの型は content なので、message を使ってはいけない」といった例外を把握する必要があり、認知負荷が上がります。
型の構造に関するルールをコードで明示的に表現して、統一感のある保守性が高いコードにしたい。本記事ではこれを実現する 2 つの方法を紹介します。
satisfies 演算子のおさらい
話が少しそれますが、TypeScript 4.9 で導入された satisfies 演算子を使うと、値が特定の型を満たすかチェックできます。
type NotificationBase = {
type: string;
message: string;
} & Record<string, unknown>;
// 値に対してはsatisfiesでチェックできる
const emailNotification = {
type: "email",
message: "Hello",
address: "test@example.com",
} satisfies NotificationBase; // ✅ OK
const brokenNotification = {
type: "push",
content: "Hello", // messageがない
deviceToken: "xxx",
} satisfies NotificationBase; // ❌ エラー!
これはまさに型に対して私がやりたいことを値に対して実現しています。「NotificationBase の構造を満たしているか」をチェックすることができています。
しかし、型に対して satisfies 演算子はサポートされていません。
// こういうことはできない
type EmailNotification = {
type: "email";
message: string;
address: string;
} satisfies NotificationBase; // ❌ 構文エラー
そこで、型に対してもsatisfies演算子のようなことができないかを考えました。
型に対してsatisfies演算子のようなチェックができれば、型のルールをコードで表現でき、私の目的が達成できそうです。
方法 1:Satisfies 型を定義しチェックする
型に対して satisfies のようなチェックができないか調べていたところ、Reddit のこちらの投稿で紹介されていた方法が参考になりました。
satisfies 演算子と同じことを型レベルで行うユーティリティ型を作れます。
type Satisfies<Constraint, Target extends Constraint> = Target;
たった 1 行ですが、これで「Target が Constraint を満たすか」をチェックできます。
実際に使っている例を紹介します。
// すべての通知型が満たすべき制約
type NotificationBase = {
type: string;
message: string;
};
// 各通知型を定義し、Satisfiesでチェック
type EmailNotification = Satisfies<
NotificationBase,
{
type: "email";
message: string;
address: string;
}
>;
type SlackNotification = Satisfies<
NotificationBase,
{
type: "slack";
message: string;
channelId: string;
}
>;
制約を満たさない型を定義しようとすると、エラーになります。
type PushNotification = Satisfies<
NotificationBase,
{
type: "push";
content: string; // messageがない!
deviceToken: string;
}
>;
// ❌ Type '{ type: "push"; content: string; deviceToken: string; }' does not satisfy the constraint 'NotificationBase'.
// Property 'message' is missing in type '{ type: "push"; content: string; deviceToken: string; }' but required in type 'NotificationBase'.
これによって必ず制約を満たす型を定義することができます!
ここまでのコード例はこちらの Playgroundで確認できます。
また、ユニオン型を組み立てる箇所でチェックを入れることもできます。
// 個別の型定義
type EmailNotification = {
type: "email";
message: string;
address: string;
};
type SlackNotification = {
type: "slack";
message: string;
channelId: string;
};
// ユニオン型を組み立てるときにまとめてチェック
type Notification = Satisfies<
NotificationBase,
// 新しい型を追加した時は必ず以下のユニオンに追加してもらう
EmailNotification | SlackNotification
>;
型定義ごとに Satisfies 型を使う方法だと型定義時に Satisfies 型を使い忘れた時に型構造の強制ができません。新しい型を追加するときにユニオンに必ず追加する運用なら自然とチェックが働きます。
方法 2:ベース型で構造を強制する
Satisfies 型を使う方法以外にジェネリクスを使う方法もあります。
ジェネリクスを使ってベースとなる型を定義し、その型を継承することで構造を強制します。
type NotificationBase<
TypeId extends string,
Extra extends Record<string, unknown> // 型ごとにカスタマイズしたものを指定する
> = {
type: TypeId;
message: string;
} & Extra;
この型を使って、各通知型を定義します。
type EmailNotification = NotificationBase<"email", { address: string }>;
type SlackNotification = NotificationBase<"slack", { channelId: string }>;
type SmsNotification = NotificationBase<"sms", { phoneNumber: string }>;
type Notification = EmailNotification | SlackNotification | SmsNotification;
この方法では、共通部分と個別部分の境界が一目瞭然になります。
type NotificationBase<
TypeId extends string,
Extra extends Record<string, unknown>
> = {
type: TypeId; // ← 共通
message: string; // ← 共通
} & Extra; // ← 個別(型ごとにカスタマイズ)
また、型引数を指定しないとエラーになるため、型構造の強制ができます。
// 型引数が足りないのでエラー
type BrokenNotification = NotificationBase;
// ❌ Generic type 'NotificationBase' requires 2 type argument(s).
新しい通知型を追加する開発者は、NotificationBase を見れば「何を指定すればいいか」がすぐにわかります。
ここまでのコード例はこちらの Playgroundで確認できます。
私が実務で採用したのはこちらのアプローチでした。Satisfies 型の存在は後から知って「こんなシンプルな書き方があったのか!」と驚きました。
結果的にベース型を使う方法は「何が共通で何がカスタマイズ可能か」が型定義を見ただけでわかるので、後からコードを見返してもわかりやすくはありました。
ただし、型定義が複雑になると、ベース型を使う方法では型引数が多くなり、可読性が低下します。
2 つの方法の使い分け
どちらの方法でも今回の目的を達成できますが、それぞれ特徴が異なります。
| 観点 | Satisfies 型 | ベース型 |
|---|---|---|
| 既存コードへの導入 | 容易 | 型定義の変更が必要 |
| 構造の明示性 | やや低い | 高い(一目瞭然) |
| 制約の強さ | 緩い(最低限の制約) | 厳密 |
| 型定義の可読性 | 変化しない | 型引数が多いと低下する |
Satisfies 型を使ってチェックする方法
- 型の定義自体は自由に行い、最後に制約を満たしているかをチェックするアプローチ
- 既存の型定義を変更せずに後からチェックを追加できるため、すでにコードベースがある状況にも導入しやすい
- 他のプロパティも自由に含められるので最低限必要なプロパティがあれば OK という緩い制約に留めたい場合にも適している
- 型定義自体の書き方は変わらないので可読性が低下しない
ベース型を定義する方法
- 共通部分をベース型に集約し、個別部分を型引数で受け取るアプローチ
- ベース型を見れば「何が共通で、何をカスタマイズできるのか」が一目でわかるため、新しく型を追加する開発者が迷いにくい
- 構造をより厳密に統一したい場合に適している
- 型引数が多くなると可読性が低下する
まとめ
discriminated union で複数の型を定義するとき、構造に関する暗黙のルールをコードで表現することで、将来の拡張時にも統一感のある実装を保てます。
- Satisfies 型によるチェック:各型を自由に定義し、制約を満たすかを Satisfies 型でチェックする
- ベース型を定義する方法:共通部分をベース型に集約し、個別部分を型引数で受け取ることで構造そのものを強制する
値に対しては satisfies があるように、型に対しても「満たすべき制約」を表現できるとコードの保守性が上がります。よかったら活用してみてください。
Discussion