ゼロランタイムで fetch に型をつけたい
まだライブラリ化してないのと、フルパス対応してないけど、いじれば使えると思う。
これは何
こういう感じに 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
めっちゃほしい