🐈

ゼロランタイムで fetch に型をつけたい

2023/05/18に公開1

まだライブラリ化してないのと、フルパス対応してないけど、いじれば使えると思う。

これは何

こういう感じに fetch に型がついて動く

import { type TypedFetch, JSON$StringifyT, JSON$ParseT } from "./typed-fetch";

const stringifyT = JSON.stringify as JSON$stringifyT;

// こんな感じの記法で型情報を与える
const fetch = window.fetch as TypedFetch<{
  "/api/:xxx": {
    method: "GET";
    bodyType: { text: string; number: number; boolean: boolean };
    headersType: { "Content-Type": "application/json" };
    responseType: { text: string; number: number; boolean: boolean };
  } | {
    method: "POST";
    bodyType: { postData: number };
    headersType: { "Content-Type": "application/json" };
    responseType: { ok: boolean };
  };
  "/api/nested/:pid": {
    method: "GET";
    bodyType: { nested: 1 };
    headersType: { "Content-Type": "application/json" };
    responseType: { text: string; number: number; boolean: boolean };
  };
}>;
const res = await fetchT("/api/xxxeuoau", {
  method: "GET",
  headers: {
    "Content-Type": "application/json",
  },
  body: stringifyT({ text: "text", number: 1, boolean: true }),
});
// ここでちゃんと型が付く
const _data: { text: string, number: number, boolean: boolean } = await res.json();

適当に作ったのでフォーマットは適当。本当は OpenAI のスキーマとかから逆算したほうがいいと思うけど、自分は自分が使う範囲に明示的に型を付けるために作ったので、あとは目的に応じて定義自体を書き直す。

GET でも body: JSON.stringify() しててクエリパラメータになってないのとかは、適当に作ったからです。(自分は fetch でほぼ GET 使った記憶ない)

なぜ作ったか

生 fetch 派閥。fetch は十分に便利なので、 axios とかなんだかの HTTP Client を使いたくない。また、ビルド環境が厳しい時に使いたいので、ゼロランタイム。

自分しかわからない目的: 今、自分はTS用の静的解析器を作っていて、ESM をトップダウンで解析することで、mangle できる変数を解析できるようになった。しかし、ESM のインターフェース以外にもコード実行時に副作用が発生する事は多々あり、特に外界への副作用はできるかぎり捕捉したい。特に使用頻度が多いであろう fetch に型を付けることで、何が公開インターフェースか逆算できるようにした。ビルドサイズに厳しい環境で仕事をしてるのもある。

どうやって実現したか

インターフェースを見れば察することも多いだろうが、さしあたり標準ライブラリを拡張した型を定義して、それを使っている。
特に String の Opaque 型をつくって、型情報を string 参照経由で持ち越すテクニックは必須だった。

// 標準の型に型情報を被せたもの
export type TypedString<T> = string & { _: T };

export type JSON$stringifyT = <T>(
  data: T,
  replacer?: undefined,
  space?: number | string | undefined,
) => TypedString<T>;

export type JSON$parseT = <T, OS extends TypedString<T>>(
  text: TypedString<T>,
) => OS["_"];

export interface RequestInitT<
  Method extends string,
  T,
  HT extends Record<string, string> = {},
> extends RequestInit {
  method: Method;
  body?: TypedString<T>;
  headers?: HT;
}

export interface ResponseT<T> extends Response {
  text(): Promise<TypedString<T>>;
  json(): Promise<T>;
}

これがあれば JSON.stringify() に明示的な型を付けることができる

const stringifyT = JSON.stringify as JSON$stringifyT;
const parseT = JSON.parse as JSON$parseT;

type T = {n: number};

const data: T = { n: 1 };
const str = stringifyT(data);

// 型チェックが効く
const parsed: T = parseT(str);

このとき、 replacer などを使えなくしている。型を推論するのが困難になるので。使用頻度は低いし気にならないはず。

あとはこの Opaque な型を受け取るために、 fetch の型を気合でこねている。

// 抜粋
export type TypedFetch<FEMap extends FetchEffectMapBase> = <
  Input extends string,
  Method extends string,
  // filter route
  ActiveRouteMap extends {
    [P in keyof FEMap]: Input extends PathPattern<P>
      ? FEMap[P] & { _pattern: P }
      : never;
  }[keyof FEMap],
  // filter method
  ActiveMethodMap extends {
    [P in keyof ActiveRouteMap]: Method extends ActiveRouteMap["method"]
      ? ActiveRouteMap
      : never;
  }[keyof ActiveRouteMap],
  // filter exact
  ActiveExactMap extends {
    [P in keyof ActiveMethodMap]:
      ExactPathPattern<Input, PathPattern<ActiveMethodMap["_pattern"]>> extends
        true ? ActiveMethodMap : never;
  }[keyof ActiveMethodMap],
>(
  input: Input,
  init: RequestInitT<
    Method,
    ActiveExactMap["bodyType"],
    ActiveExactMap["headersType"]
  >,
) => Promise<ResponseT<ActiveExactMap["responseType"]>>;

パスの変形の部分はかなり改善の余地がある。動けばヨシで作ったので...

簡単に説明すると、RequestInitT<T> 型をとって、 Promise<ResponseT<T>> を返す。このときの型は stringifyT<T> から推論して引き回しているので、 response.json() まで引き継がれていて、型が出てくる。

full code

// 標準の型に型情報を被せたもの
export type TypedString<T> = string & { _: T };

export type JSON$stringifyT = <T>(
  data: T,
  replacer?: undefined,
  space?: number | string | undefined,
) => TypedString<T>;

export type JSON$parseT = <T, OS extends TypedString<T>>(
  text: TypedString<T>,
) => OS["_"];

export interface RequestInitT<
  Method extends string,
  T,
  HT extends Record<string, string> = {},
> extends RequestInit {
  method: Method;
  body?: TypedString<T>;
  headers?: HT;
}

export interface ResponseT<T> extends Response {
  text(): Promise<TypedString<T>>;
  json(): Promise<T>;
}

// URL のパターンを抽出する
export type PathPattern<T extends string | number | symbol> = T extends
  `/${infer Head}/${infer Body}/${infer Tail}`
  ? Tail extends "" ? `${PathPattern<`/${Head}`>}${PathPattern<`${Body}`>}`
    // folding to 2 term
  : `${PathPattern<`/${Head}`>}${PathPattern<`/${Body}/${Tail}`>}`
  // 2 term
  : T extends `/${infer Head}/${infer Tail}`
    ? Tail extends "" ? PathPattern<`/${Head}`>
    : `/${Head}${PathPattern<`/${Tail}`>}`
  // 1 term dynamic
  : T extends `/:${string}` ? `/${string}`
  : T;

// マッチしたパターンでもう一度 / 付きで食わせてみて、必要以上に食ってないかを確認
// まだ使えてない。混ざる可能性がある。
type ExactPathPattern<Static extends string, Statement extends string> =
  Static extends `${Statement}/${string}` ? false : true;

type FetchEffectMapBase = {
  [pattern: string]: {
    method: string;
    bodyType: any;
    headersType: any;
    responseType: any;
  };
};

export type TypedFetch<FEMap extends FetchEffectMapBase> = <
  Input extends string,
  Method extends string,
  // filter route
  ActiveRouteMap extends {
    [P in keyof FEMap]: Input extends PathPattern<P>
      ? FEMap[P] & { _pattern: P }
      : never;
  }[keyof FEMap],
  // filter method
  ActiveMethodMap extends {
    [P in keyof ActiveRouteMap]: Method extends ActiveRouteMap["method"]
      ? ActiveRouteMap
      : never;
  }[keyof ActiveRouteMap],
  // filter exact
  ActiveExactMap extends {
    [P in keyof ActiveMethodMap]:
      ExactPathPattern<Input, PathPattern<ActiveMethodMap["_pattern"]>> extends
        true ? ActiveMethodMap : never;
  }[keyof ActiveMethodMap],
>(
  input: Input,
  init: RequestInitT<
    Method,
    ActiveExactMap["bodyType"],
    ActiveExactMap["headersType"]
  >,
) => Promise<ResponseT<ActiveExactMap["responseType"]>>;

// import { type TypedFetch, JSON$StringifyT } from "./typed-native";

const stringifyT = JSON.stringify as JSON$stringifyT;
const fetchT = fetch as TypedFetch<{
  "/api/:xxx": {
    method: "GET";
    bodyType: { text: string; number: number; boolean: boolean };
    headersType: { "Content-Type": "application/json" };
    responseType: { text: string; number: number; boolean: boolean };
  } | {
    method: "POST";
    bodyType: { postData: number };
    headersType: { "Content-Type": "application/json" };
    responseType: { ok: boolean };
  };
  "/api/nested/:pid": {
    method: "GET";
    bodyType: { nested: 1 };
    headersType: { "Content-Type": "application/json" };
    responseType: { text: string; number: number; boolean: boolean };
  };
}>;
const res = await fetchT("/api/xxxeuoau", {
  method: "GET",
  headers: {
    "Content-Type": "application/json",
  },
  body: stringifyT({ text: "text", number: 1, boolean: true }),
});
const _data: { text: string, number: number, boolean: boolean } = await res.json();


fetchT("/api/nested/aaa", {
  method: "GET",
  headers: {
    "Content-Type": "application/json",
  },
  // 完全一致しないと混ざらないので大丈夫
  // @ts-expect-error
  body: stringifyT({ text: "text", number: 1, boolean: true }),
});

await fetchT("/api/xxxeuoau", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  // メソッドが違ったら混ざらないので大丈夫
  // @ts-expect-error
  body: stringifyT({ text: "text", number: 1, boolean: true }),
});

改善の余地があるのでご協力よろしくお願いします。

Discussion