はじめに
例えば、User
という型に、「管理者」と「スタッフ」の2つのロールがあるとします。
type UserType = {
role: "admin" | "staff";
admin_num?: num; // 管理者なら必須
// 以下共通
id: number;
name: string;
email: string;
}
管理者なら(role
プロパティが"admin"
なら)、管理者番号(admin_num
プロパティ)を必須にしたいですが、このtypeだと制約できません。
// admin_numがなくてもコンパイルが通る
const admin: UserType = {
role: "admin",
id: 1,
name: "Admin Example",
email: "email"
}
というように、「あるプロパティの型を、同じtype内にある他プロパティの値ごとに変える」型宣言をしたいケースがあると思います。
本記事では、TypeScriptでそれを実装する方法についてまとめます。
条件になるプロパティが、リテラル型の場合に限ります
環境
- typescript: v5.8.3
型の実装方法
1. Union型
実装方法の一つは、Union型を用いることです。TypeScriptは条件となるプロパティによって、Union型のメンバーを判別し特定できます。
やりたいことは↑の記事で書かれている通りですが、冒頭の例を書き直すとこうなります。
// 共通部
type UserInfoType = {
id: number;
name: string;
email: string;
}
type AdminType = UserInfoType & {
role: "admin";
admin_num: number;
}
type StaffType = UserInfoType & {
role: "staff";
}
type UserUnionType = AdminType | StaffType // ← Userの型
この型を用いれば、型制約を満たしていないとコンパイルエラーになります。
const admin: UserUnionType = {
~~~~~
role: "admin",
id: 1,
name: "Admin One",
email: "email"
}
error TS2322: Type '{ role: "admin"; id: number; name: string; email: string; }' is not assignable to type 'UserUnionType'.
Type '{ role: "admin"; id: number; name: string; email: string; }' is not assignable to type 'AdminType'.
Property 'admin_num' is missing in type '{ role: "admin"; id: number; name: string; email: string; }' but required in type '{ role: "admin"; admin_num: number; }'.
これで意図した型制約を実装することができました。
宣言時の制約以外にも、if文やswitch文によるrole
の分岐で、型の絞り込みもできます。
const user: UserUnionType = { ... }
if (user.role === "staff") return
// user is AdminType
user.admin_num // number, not undefined
2. Genericsを使う
先程と同じ型を、GenericsとConditional Typesを用いて記述することもできます。
type UserRoleType = "admin" | "staff";
type UserRoleGenericsType<T extends UserRoleType> = T extends "admin" ?
{
role: T;
admin_num: number;
} :
{
role: T;
}
type UserGenericsType = {
id: number;
name: string;
email: string;
} & UserRoleGenericsType<UserRoleType>
// 以下はコンパイルエラー
const staff: UserGenericsType = {
role: "staff",
admin_num: 2,
~~~~~~~~~
id: 2,
name: "Staff Two",
email: "email"
}
Union型の実装と同様に型の絞り込みもできます。
ただ、こちらの場合ロールの種類が増えると、Conditional Typesでextends地獄が始まります。また、各ロールごとのtypeも必要になったら作る必要があります。
type ExtractAdminType = Extract<UserGenericsType, { role: "admin" }>
type ExtractStaffType = Extract<UserGenericsType, { role: "staff" }>
注意
UserRoleGenericsType
にあるrole: T
は、一見同じプロパティとして共通化できそう(以下コード参照)ですが、この実装ではうまく判定されません。
type UserRoleGenericsType<T extends UserRoleType> = T extends "admin" ?
{ admin_num: number; }
: {}
type UserGenericsType = {
id: number;
name: string;
email: string;
role: UserRoleType; // 共通なので移動 → 型制約できなくなる
} & UserRoleGenericsType<UserRoleType>
つまり、外部のtypeのプロパティを条件にしたいときは、分岐先のtypeの方にもそのプロパティの宣言が必要です。
// 外部からimport
type ImportedUserInfoType = {
id: number;
name: string;
email: string;
role: UserRoleType;
}
---
type UserRoleGenericsType<T extends UserRoleType> = T extends "admin" ?
{
role: T; // 分岐先でも宣言
admin_num: number;
} :
{
role: T;
}
type UserType = ImportedUserInfoType & UserRoleGenericsType<UserRoleType>
おわりに
以上が「あるプロパティの型を、他プロパティの値ごとに変える」型宣言をする方法でした。
最初自分は、2.のGenericsを使う方で実装してましたが、こう見るとUnion型の方がシンプルで良さそうだと感じました。