Nuxt3のuseFetchの型定義を探索してみたら結構面白かった話

この記事は個人ブログと同じ内容です

kotamat.com

先日10/12にNuxt3がpublic betaになりました!🎉 Nuxt2から抜本的に変更されたNuxt3では面白い変更点が多いのですが、今回はuseFetchの挙動に関して探索してみようと思います。

useFetchとは?

Data Fetchingにて紹介されている非同期データ取得APIのうちの一つ。

useFetch(url: string, options?)

というような形で呼び出す、非常にシンプルなAPIではあるのですが、useAsyncData$fetch のラッパーであったり、自動生成されたローカルAPIのレスポンスの型を提供するということで、型定義はかなり複雑な形になっています。

まずは型定義から

useFetchの型定義は下記のようになっています。

export declare function useFetch<ReqT extends string = string, ResT = FetchResult<ReqT>, Transform extends (res: ResT) => any = (res: ResT) => ResT, PickKeys extends KeyOfRes<Transform> = KeyOfRes<Transform>>(url: ReqT, opts?: UseFetchOptions<ResT, Transform, PickKeys>): import("./asyncData").AsyncData<import("./asyncData").PickFrom<ReturnType<Transform>, PickKeys>>;

型パズル感すごいですね… ここからは一つずつ紐解いていくことにします

Transform剥がし

TransformはuseAsyncDataのオプションの一つである、返り値の変換器であるため、一旦削ってシンプルにしてみます。

export declare function _useFetch<ReqT extends string = string, ResT = FetchResult<ReqT>, PickKeys = KeysOf<ResT>>(url: ReqT, opts?: UseFetchOptions<ResT, (input: ResT)=> ResT, PickKeys>): import("./asyncData").AsyncData<import("./asyncData").PickFrom<ResT, PickKeys>>;

※この状況だとUseFetchOptionsの第3型引数がエラーになってしまいますが、型の抽象度を意図的に変えてしまったのが原因なので一旦無視します

返り値に注目

ここで返り値に注目してみます。

asyncData.d.tsでは下記のように定義されているため

// Tのうち、Kの配列の要素に指定されたkeyの要素だけを抽出して取り出す
export declare type PickFrom<T, K extends Array<string>> = T extends Record<string, any> ? Pick<T, K[number]> : T;
...
// Dataの型を、asyncDataが返してくれる型に変換する
export interface _AsyncData<DataT> {
    data: Ref<DataT>;
    pending: Ref<boolean>;
    refresh: (force?: boolean) => Promise<void>;
    error?: any;
}
export declare type AsyncData<Data> = _AsyncData<Data> & Promise<_AsyncData<Data>>;

返り値は「ResT から PickKeysで指定した要素だけをとりだし、asyncDataが返してくれる形に変換した型」となります。

ResTの探索

では ResT も探索してみます。

ResT = FetchResult<ReqT>;
export declare type Awaited<T> = T extends Promise<infer U> ? U : T;
export declare type FetchResult<ReqT extends string> = Awaited<ReturnType<$Fetch<unknown, ReqT>>>;

となっているため、「ResT$Fetch<unknown, ReqT>の返り値のPromiseを剥がしたもの、もしくはそれ自体」となります。

$Fetchの探索

$Fetchを見てみると下記の様になっています。

export declare interface $Fetch<T = unknown, R extends FetchRequest = FetchRequest> {
  (request: R, opts?: FetchOptions): Promise<TypedInternalResponse<R, T>>
  raw (request: R, opts?: FetchOptions): Promise<FetchResponse<TypedInternalResponse<R, T>>>
}

ResTを当てはめResTの算出に必要なものだけを残すと

export declare interface $Fetch<unknown, ReqT> {
  (request: R, opts?: FetchOptions): Promise<TypedInternalResponse<ReqT, unknown>>
}

となります。

TypedInternalResponseを見ていくと下記になっているので

export declare type TypedInternalResponse<Route, Default> =
  Default extends string | boolean | number | null | void | object
    // Allow user overrides
    ? Default
    : Route extends string
      ? MiddlewareOf<Route> extends never
        // Bail if only types are Error or void (for example, from middleware)
        ? Default
        : MiddlewareOf<Route>
      : Default

今回の型で置き換えると

export declare type TypedInternalResponse<ReqT, unknown> =
    ReqT extends string
      ? MiddlewareOf<ReqT> extends never
        // Bail if only types are Error or void (for example, from middleware)
        ? unknown
        : MiddlewareOf<ReqT>
      : unknown

となります。neverを一旦無視するとMiddlewareOf<ReqT>が返ってくるといえそうです。

残りの型定義は下記となるので

export declare interface InternalApi { }

export declare type ValueOf<C> = C extends Record<any, any> ? C[keyof C] : never

export declare type MatchedRoutes<Route extends string> = ValueOf<{
  // exact match, prefix match or root middleware
  [key in keyof InternalApi]: Route extends key | `${key}/${string}` | '/' ? key : never
}>

export declare type MiddlewareOf<Route extends string> = Exclude<InternalApi[MatchedRoutes<Route>], Error | void>

MiddlewareOf<ReqT>に当てはめて考えてみると

export declare interface InternalApi { }

export declare type ValueOf<C> = C extends Record<any, any> ? C[keyof C] : never

export declare type MatchedRoutes = ValueOf<{
  // exact match, prefix match or root middleware
  [key in keyof InternalApi]: ReqT extends key | `${key}/${string}` | '/' ? key : never
}>

export declare type MiddlewareOf = Exclude<InternalApi[MatchedRoutes<ReqT>], Error | void>

となります。ざっくりいうと、「InternalApiの中にReqTがキーとなる物があればそれのValueを返却する」というふうに解釈できますね。

つまり「ResTInternalApiのキーがReqTに相当するもののValueの型」と推察できます。

InternalApiの自動生成

上記型定義だとInternalApiはデフォルトで {}です。つまりこのままではなんの意味もないものになります。 この型定義をoverwriteしてくれるのがnitroというNuxt3のサーバーエンジンです。

nitroは色々な機能があるのですが、そのうちの一つが /server/ディレクトリに配置した関数の返り値を解釈し、型定義を生成してくれるというものです。

例えば/server/api/count.tsという下記のTSファイルを設置してみます。

let counter = 0;
export default (): { counter: number } => {
  counter++;
  return { counter };
};

すると.nuxt/nitro.d.tsという、下記の内容のファイルが生成されます。

declare module '@nuxt/nitro' {
  interface InternalApi {
    '/api/count': ReturnType<typeof import('../server/api/count').default>
  }
}
export {}

InternalApiが拡張され、/api/countに対する型定義が出現しました。 これにより、「ResTInternalApiの中にあるReqTに相当するもののValueの型」は 「ResTReqT/api/countのとき/server/api/countの返り値」となることができました。

useFetchの返り値

ここでuseFetchを実際に使ってみてこの効果を探ってみます。

const {data} = await useFetch('/api/count')

としてみたとき、dataの型はシンプルに

Ref<Pick<{
    counter: number;
}, "counter">>

となります。 useFetchでは単にエンドポイントを指定しているだけに過ぎないのですが、その返り値がVue3で用いやすい型として抽出されている事がわかります。 \ InternalApiに型定義がなくても使えるような設計になっているため、useFetchの引数に対しての補完が効かないのが難点ではありますが、Nuxtのディレクトリ構造を探索すればファイル名だけである程度予想はできるので、便利に使えそうです。

とりあえず一旦まとめ

useFetchの返り値を探索するだけで結構長くなったので、一旦このへんで終わりにします。 \ useFetchはこれ以外にもuseAsyncData$fetchのオプションもサポートしているので、よかったら探索してみてください。オプションのサポートは上記に比べると大したことはないので、気構えずに見れると思います