infer の使い方を理解する
なぜ
フロントで swagger の json からレスポンスとクエリの型を取得するさいに infer を使用して型安全に実装したが、 infer の使用方法で理解不足だった箇所をまとめることにより理解を深める。
各場所での記述のバラツキ
type paths = {
"/api/user": {};
"/api/posts": {};
};
const API_PAHT ={
USER: 'api/user',
CORP: 'api/corp'
}
実装
失敗例
swagger の方に不要な / が付いてるので消そう(名案!)
と考えてバックエンドの記述を変更して先頭の /が無い下記形に変更しました。
type paths = {
+ "api/user": {};
+ "api/posts": {};
- "/api/user": {};
- "/api/posts": {};
};
フロントもバックエンドも統一できて楽勝!!っと思いましたが、swagger の仕様上 / は必要で削除するとブラウザで確認したさいにエラーがでました。
成功例
先程のバックエンドの修正は元に戻して、フロントのみで対応することに。
type PathKeysWithoutSlash = keyof paths extends `/${infer R}` ? R : never
// APIのパスを受け取って / を追加後、openapi の型からパスに対応するクエリの型を取得するヘルパー型
export type GenerateQueryParams<T extends PathKeysWithoutSlash> = paths[`/${T}`]['get'] extends {
parameters: { query: infer Q }
}
? Q
: never
infer 解説
TypeScript の infer は、条件型の中で「型の一部を推論する」ときに使います。
type PathKeysWithoutSlash = keyof paths extends `/${infer R}` ? R : never
上記部分で、 infer R には、paths の key の / から始まる文字列の / 以降が入ってきます。
それ以外は、 never を返します。
例えば、下記の場合
PathKeysWithoutSlash は、 api/user | api/posts になります。
type paths = {
"/api/user": {};
"/api/posts": {};
};
そして、swagger の型ファイルへアクセスするときは、 / を付加してAPIに使用する paht をジェネリクスに渡せば型安全にクエリやレスポンスの型を取得できます。
import { API_PATH } from '@/constants/text/apiPath'
import { GenerateQueryParams } from '@/type/api/common'
type UserQuery = GenerateQueryParams<API_PAHT.USER>
余談
このように記述するとエラーになります。
+ type PathKeysWithoutSlash = keyof paths extends `/api${infer R}` ? R : never
- type PathKeysWithoutSlash = keyof paths extends `/${infer R}` ? R : never
type '`/api${T}`' cannot be used to index type 'paths'.ts(2536)
解決方法
type PathKeysWithoutApi = keyof paths extends infer K
? K extends `/api${infer R}`
? R
: never
: never;
以下chatGPT
プレフィックスが /api のように特定のパターンを含むと、TypeScript が条件評価を複雑に解釈する可能性があり、正しく動作しない場合があるため、回避策が必要になります。
そのため、keyof paths を一度 infer でキャプチャしてから条件を適用する方法(infer K を使う)が必要になる場合があります。
なぜこの方法で解決するのか
TypeScript の条件型では、ユニオン型に対して extends を使うと、各要素ごとに条件が適用される仕組み(ディストリビューション)があります。
K = "/api/user" | "/api/corp"
個別に適用:
"/api/user" → /api${infer R} にマッチ → R = "/user"
"/api/posts" → /api${infer R} にマッチ → R = "/corp"
「ディストリビューション」とは、TypeScriptの条件型(extends を使った型)にユニオン型が適用されるとき、TypeScriptはそのユニオン型の 各要素ごとに条件を評価する という仕組みがあります。
これを ディストリビューション(分配) と呼びます。
条件型とは?
条件型は、A extends B ? C : D の形を持つ型です。
type A = "apple" | "banana" | "cherry";
type Check<T> = T extends "apple" ? true : false;
type Result = Check<A>;
// Result は true | false | false となり、型としては true | false
infer とディストリビューションの組み合わせ
type Paths = "/api/user" | "/api/posts";
type ExtractAfterApi<T> = T extends `/api${infer R}` ? R : never;
type Result = ExtractAfterApi<Paths>;
解説
- Paths は "/api/user" | "/api/posts" というユニオン型です。
- ExtractAfterApi<T> の条件型に extends と infer を組み合わせています。
- TypeScriptはユニオン型の各要素に対してディストリビューションを行います:
a. "/api/user" → /api${infer R} にマッチ → R = "/user"
b. "/api/posts" → /api${infer R} にマッチ → R = "/posts" - 結果として Result は "/user" | "/posts" になります。
ディストリビューションが適用されないケース
ディストリビューションは 条件型 の場合のみ適用されます。型が条件型でない場合、ディストリビューションは行われません。
type A = "apple" | "banana" | "cherry";
type NoDistribution<T> = (T extends "apple" ? true : false);
type Result = NoDistribution<A>;
- 注意点: NoDistribution<A> の T が () でラップされているため、ディストリビューションが適用されません。
- 結果: T 全体("apple" | "banana" | "cherry")が一括で評価されます。
- 条件は ("apple" | "banana" | "cherry") extends "apple" ? true : false となり、
false になります。
- 条件は ("apple" | "banana" | "cherry") extends "apple" ? true : false となり、
Discussion