【TypeScript】定数定義を型でクエリする

## はじめに

- この記事は [JSL (日本システム技研) | Advent Calendar 2022](https://qiita.com/advent-calendar/2022/jsl) 2日目です
- TypeScriptヲタク向けの内容かもしれません

## 環境
- TypeScript v4.9.3

## 内容

### 導入

TypeScriptで定数定義をするとき `as const` を使うと深い階層の型までリテラル型に推論されます。
ということは、「型をクエリする」ことで定数定義の値を安全に絞り込むことができるのでは？と思いました。

何言ってんだコイツ？と思った方、申し訳ありません…。例を出して説明します。

定数定義はこれで、カフェのメニューマスタのようなものをイメージしています。

```ts
type MenuData = {
    name: string;
    price: number;
    isHot: boolean;
};

const menuMaster = {
   1: {
      name: 'アイスコーヒー',
      price: 200,
      isHot: false
   },
   2: {
      name: 'コーヒー',
      price: 220,
      isHot: true
   },
   3: {
      name: '緑茶',
      price: 150,
      isHot: true
   },
   4: {
      name: 'チャイティー',
      price: 220,
      isHot: false
   }
} as const satisfies Record<number, MenuData>;
// ちゃっかり使われてる satisfies 君は定数定義のまさに救世主です（詳しくは別途書きます）
```

ゴールは「name: 'コーヒー' の price の型」を取得するなどです。

```ts
// XXXX が今回作りたいやつ
type CoffeePrice = XXXX<typeof menuMaster, 'name', 'コーヒー'>['price']; // 220
```

これができて何が嬉しいかというと、クエリをTypeScriptの型推論に任せることで実際にクエリコードを書く必要を無くせて、バグ・テストを減らすことができることです。

では、実際にやってみましょう。

### 実践

[PlayGround](https://www.typescriptlang.org/play?ssl=57&ssc=39&pln=53&pc=1#code/C4TwDgpgBAshB2BXAIgQ2KqBeKBvAUFEVPKgLYQBcUAzsAE4CW8A5gNyHFhMDGVJiMgCMI9DsSiMaACQD2wakNmyANhFTwOAXw74es+HSgUkMVHVHY8nKAEZqBCUVIVqAckBFDIBKGQJ0MgZoZAH4ZAJYYAtwAaGy5efgAmAAY4iKdJGXlqADNUFRoIGy0kohiHSOdyfjdAkPCSqG5GPmoY+IKJKTkFKAZEXOJ8mwBmYuSXcsBF30A2YOrkuoa7AFZEmra0zvpuvJaAFiGnEfceAAtURimnGdjmpdSOzOyeoi18LShzKH1DYFp0KXTGCBooAAlCD6egAEwAPEhhKIwrAECh0KgAHy6UCQKDIeqfHDA0GQuhMVhQAA+Ahh9DhiHgAGt4LIAO7wVH4dHQABqWW6AHl0hCACrIqz8gDaNIgIFk6Sg-IAumjwNAAAr1GkAYQOjBUYPoCAhnH5UAgAA9gAgwQC8bJwRDCcwWHCsTxgMiCgBVI2m80A8WS6WclQ8vmC-BCnCOKAigDSkngUF9UplsuoKp4NIF0dlcLdLJ0+FZiqgADEtWb6ALPWb4BbMdi4UWw9YiNHY-GJYnDa9RVHZZXvcWoAB+KAx6jwCAAN1EyZlme0CoxAEVuvQQABJZD6ohoDB96uWkHWgkMe2k8kiSm152uzgliDavc1hP+rkQXkQncogoB7qPgHUulGXgUMrGfYtS1ELcoFTdVNW1XV4A-JF6z+bUbyIK0bTvbU4R-CBkVDBdoGXUQQCgz8-yBQ8bTtYkyWhC9HWxdDwPvMFKLAvD30-Fi8Mo6kwQgX5x3YslcGeMkuL5HiRWwsFZRAnBPxFEjVw3JCMBQtjcNfZF5XzAsMTVKV0ggCAlWiKxVLItlExMRAzAsS83BGcIoAqYJQmREU3HONx5SgAB6QKoCaOJ8zZKB2m5eAVBAAA5MorJXGzFTshFHLLOE3GWYA3K6fCfNcgLgqgSoAjPCYIsLYz0lMiAABlGHFOAkGS0iIVs6V7My2F3NcuEAAMABJcFolgtHK0bxq0QbUSCkLyrPbx-E86qMWi2KQFaxAN3atTNy64wMvMLL3Ny-K1nw3R3iMA55B2hrUBEFQzDAKwI1C9xABEGQATBkAIIZADsGQAQhl+04oEGdzAG8GQBjBkACwZAAA5QB7AzcJ4XgBW7Phob4aF+f4qPxCFNrinaNzhcaWSAA) で試すことができますので合わせてどうそ。

まずはいつもの便利型たちを定義して…

```ts
type Dict = Record<string | number, unknown>;
type ValueOf<T> = T[keyof T];
```

子（key-valueのvalue）の型で絞り込む型を作ります。
例えば、 `{ a: 1; b: 2 }` という型から `1` のみの型 `{ a: 1 }` を取得できます。

```ts
type Filter<T extends Dict, F> = {
  [K in keyof T as T[K] extends F ? K : never]: T[K];
};
```

重要なのは `as T[K] extends F ? K : never` で、 `F` に当てはまらないプロパティを除外しています。

次に、子を特定のプロパティのみにする型を作ります。
例えば、 `Record<string, { a: 1; b: 2 }>` という型から `Record<string, { a: 1 }>` を取得できます。

```ts
type PickChildren<
  T extends Record<string, Dict>,
  U extends keyof ValueOf<T>
> = {
  [K in keyof T]: Pick<T[K], U>;
};
```

これは以外と単純で、子の型をプロパティ名 `Field` で `Pick` しているだけです。

さて、これで材料は揃ったのでクエリ型を組み上げていきます。

`PickChildren` でマスタデータ型をクエリ対象プロパティ名 `Field` のみにした型を取得、更に `Filter` で絞り込みを行います。そして、そのキーを取得すればマスタデータの型からクエリ結果が取得できるというわけです（何言ってんだコイツ…）

```ts
type QueryID<
  Data extends Record<string | number, Dict>,
  Field extends keyof ValueOf<Data>,
  Value extends unknown
> = keyof Filter<
  PickChildren<Data, Field>,
  Record<Field, Value>
>;

type Query<
  Data extends Record<string | number, Dict>,
  Field extends keyof ValueOf<Data>,
  Value extends undefined | {} | ValueOf<Data>[Field]
> = Data[QueryID<Data, Field, Value>];
```

実際に使ってみると…

```ts
type CoffeePrice = Query<typeof menuMaster, 'name', 'コーヒー'>['price']; // 220
```

![SS 2022-12-01 20.33.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252036/e6977c23-8073-902b-0726-828204895b39.png)

やったぜ。

お次は、「あったか〜い」メニューの名前を取得してみましょう

```ts
type HotOnlyName = Query<typeof menuMaster, 'isHot', true>['name']; // コーヒー | 緑茶
```

![SS 2022-12-01 20.37.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252036/12e8a496-d6fd-7ec2-8b98-335bd003abda.png)

いいんじゃないでしょうか。

ラスト、「名前に `コーヒー` が入っているメニュー」を取得してみましょう

```ts
type CoffeeLikeMenu = Query<typeof menuMaster, 'name', `${string}コーヒー${string}`>; // コーヒー | アイスコーヒー
```

![SS 2022-12-01 20.43.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252036/1cfe6468-94a8-ba73-5ed1-a30b96a853b8.png)

よし！

まぁなんとかできましたね。で、これが輝く場面ですが、例えば「ホットメニューのみのマッピングを用意したい」ような場合

```ts
type HotOnlyMenuID = QueryID<typeof menuMaster, 'isHot', true>;

const hotMenuLabelMap = {
   2: 'いつものやつ',
   3: 'ほっと一息'
} as const satisfies Record<HotOnlyMenuID, string>;
```

こんな感じに、マスタデータの型から別の定数定義を作ることができて型制約もかかるので、「メニュー追加手順書」なんてのは不要になるわけです。型エラーが作業者へのメッセージになる…いいですよね。

## 最後に
- 型パズルは楽しいですよ（遠い目）皆さんも是非沼にハマってみてください。
- 拙い記事ですが最後まで読んでいただきありがとうございました！