はじめに
- この記事は JSL (日本システム技研) | Advent Calendar 2022 2日目です
- TypeScriptヲタク向けの内容かもしれません
環境
- TypeScript v4.9.3
内容
導入
TypeScriptで定数定義をするとき as const
を使うと深い階層の型までリテラル型に推論されます。
ということは、「型をクエリする」ことで定数定義の値を安全に絞り込むことができるのでは?と思いました。
何言ってんだコイツ?と思った方、申し訳ありません…。例を出して説明します。
定数定義はこれで、カフェのメニューマスタのようなものをイメージしています。
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 の型」を取得するなどです。
// XXXX が今回作りたいやつ
type CoffeePrice = XXXX<typeof menuMaster, 'name', 'コーヒー'>['price']; // 220
これができて何が嬉しいかというと、クエリをTypeScriptの型推論に任せることで実際にクエリコードを書く必要を無くせて、バグ・テストを減らすことができることです。
では、実際にやってみましょう。
実践
PlayGround で試すことができますので合わせてどうそ。
まずはいつもの便利型たちを定義して…
type Dict = Record<string | number, unknown>;
type ValueOf<T> = T[keyof T];
子(key-valueのvalue)の型で絞り込む型を作ります。
例えば、 { a: 1; b: 2 }
という型から 1
のみの型 { a: 1 }
を取得できます。
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 }>
を取得できます。
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
で絞り込みを行います。そして、そのキーを取得すればマスタデータの型からクエリ結果が取得できるというわけです(何言ってんだコイツ…)
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>];
実際に使ってみると…
type CoffeePrice = Query<typeof menuMaster, 'name', 'コーヒー'>['price']; // 220
やったぜ。
お次は、「あったか〜い」メニューの名前を取得してみましょう
type HotOnlyName = Query<typeof menuMaster, 'isHot', true>['name']; // コーヒー | 緑茶
いいんじゃないでしょうか。
ラスト、「名前に コーヒー
が入っているメニュー」を取得してみましょう
type CoffeeLikeMenu = Query<typeof menuMaster, 'name', `${string}コーヒー${string}`>; // コーヒー | アイスコーヒー
よし!
まぁなんとかできましたね。で、これが輝く場面ですが、例えば「ホットメニューのみのマッピングを用意したい」ような場合
type HotOnlyMenuID = QueryID<typeof menuMaster, 'isHot', true>;
const hotMenuLabelMap = {
2: 'いつものやつ',
3: 'ほっと一息'
} as const satisfies Record<HotOnlyMenuID, string>;
こんな感じに、マスタデータの型から別の定数定義を作ることができて型制約もかかるので、「メニュー追加手順書」なんてのは不要になるわけです。型エラーが作業者へのメッセージになる…いいですよね。
最後に
- 型パズルは楽しいですよ(遠い目)皆さんも是非沼にハマってみてください。
- 拙い記事ですが最後まで読んでいただきありがとうございました!