【TypeScript】typeのプロパティの型を他プロパティの値で条件分岐させる

## はじめに
例えば、`User`という型に、「管理者」と「スタッフ」の2つのロールがあるとします。
```typescript:type
type UserType = {
    role: "admin" | "staff";
    admin_num?: num; // 管理者なら必須
    // 以下共通
    id: number;
    name: string;
    email: string;
}
```
管理者なら(`role`プロパティが`"admin"`なら)、管理者番号（`admin_num`プロパティ）を必須にしたいですが、このtypeだと制約できません。
```typescript
// admin_numがなくてもコンパイルが通る
const admin: UserType = {
    role: "admin",
    id: 1,
    name: "Admin Example",
    email: "email"
}
```

---

というように、「**あるプロパティの型を、同じtype内にある他プロパティの値ごとに変える**」型宣言をしたいケースがあると思います。

本記事では、TypeScriptでそれを実装する方法についてまとめます。

:::note warn
条件になるプロパティが、[リテラル型](https://typescriptbook.jp/reference/values-types-variables/literal-types)の場合に限ります
:::

### 環境
- typescript: v5.8.3

## 型の実装方法
### 1. Union型
実装方法の一つは、Union型を用いることです。TypeScriptは条件となるプロパティによって、Union型のメンバーを判別し特定できます。

https://typescript-jp.gitbook.io/deep-dive/type-system/discriminated-unions#unionnodiscriminated-union

やりたいことは↑の記事で書かれている通りですが、冒頭の例を書き直すとこうなります。
```typescript:type
// 共通部
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の型
```
この型を用いれば、型制約を満たしていないとコンパイルエラーになります。
```typescript
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`の分岐で、型の絞り込みもできます。
```typescript
const user: UserUnionType = { ... }
if (user.role === "staff") return

// user is AdminType
user.admin_num // number, not undefined
```

### 2. Genericsを使う
先程と同じ型を、Genericsと[Conditional Types](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html)を用いて記述することもできます。
```typescript:type
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>
```
```typescript
// 以下はコンパイルエラー
const staff: UserGenericsType = {
    role: "staff",
    admin_num: 2,
    ~~~~~~~~~
    id: 2,
    name: "Staff Two",
    email: "email"
}
```

Union型の実装と同様に型の絞り込みもできます。

ただ、こちらの場合ロールの種類が増えると、Conditional Typesでextends地獄が始まります。また、各ロールごとのtypeも必要になったら作る必要があります。
```typescript:type
type ExtractAdminType = Extract<UserGenericsType, { role: "admin" }>
type ExtractStaffType = Extract<UserGenericsType, { role: "staff" }>
```

#### 注意
`UserRoleGenericsType`にある`role: T`は、一見同じプロパティとして共通化できそう（以下コード参照）ですが、この実装ではうまく判定されません。
```typescript:型制約できない例
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の方にもそのプロパティの宣言が必要です。
```typescript:条件になるプロパティが外部の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.](#2-genericsを使う)のGenericsを使う方で実装してましたが、こう見るとUnion型の方がシンプルで良さそうだと感じました。