5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

JSL (日本システム技研)Advent Calendar 2022

Day 2

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

Last updated at Posted at 2022-12-01

はじめに

環境

  • 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>;
};

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

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

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

SS 2022-12-01 20.33.34.png

やったぜ。

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

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

SS 2022-12-01 20.37.58.png

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

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

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

SS 2022-12-01 20.43.44.png

よし!

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

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

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

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

最後に

  • 型パズルは楽しいですよ(遠い目)皆さんも是非沼にハマってみてください。
  • 拙い記事ですが最後まで読んでいただきありがとうございました!
5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?