前言#
fetch("https://jsonplaceholder.typicode.com/todos/1")
.then((response) => response.json())
.then((json) => console.log(json));
あなたが上記のコードを使ってネットワークリクエストを発行すると、IDE がjson
の型ヒントを提供できないことに気づくでしょう。これは合理的です。このインターフェースがどのような形式のデータを返すかは誰にもわかりません。したがって、次のステップを実行する必要があります:
- このインターフェースを手動でテストし、返り値を取得する
- 返り値の内容に基づいて、transform.toolsのようなツールを使用して対応する型定義を生成する
- 型定義をプロジェクトにコピーする
as
を使用して返り値の型を示す
これにより、インターフェースが正常に返すときのjson
の型を得ることができますが、まだ解決すべき多くの問題があります:
- 手動リクエストで得られる返り値は正確ではありません。たとえば、インターフェースの呼び出しにエラーが発生した場合、返り値の型が変わる可能性があります
- リクエストの
url
と入力パラメータには型ヒントがありません - バックエンドインターフェースが変更されたとき、tsc はエラーを報告できません
解決策の基礎#
ts には非常に便利な機能であるas const
アサーションがあります。このアサーションを変数に追加すると、変数の型はより具体的なリテラル型として推論され、変数の変更が禁止されます。以下は簡単な例で、navigateTo
関数の引数がより安全になっていることがわかります。未定義のルート名の文字列を渡すことはできません。
const routes = {
home: "/",
about: "/about",
} as const;
declare function navigateTo(route: keyof typeof routes): void;
navigateTo("404");
// Argument of type '"404"' is not assignable to parameter of type '"home" | "about"'.
これが、型安全な fetch を実現するための基礎です。
ルート構造の定義#
まず、リクエストパス、入力、出力、および発生する可能性のあるエラーコードを含むインターフェースのすべての必要情報を含むルート構造を定義する必要があります。同時に、ここではzod
を使用して実行時のデータ形式検証を行います。
// add.ts
import { z } from "zod";
export const path = "/add" as const;
export const input = z.object({
a: z.number(),
b: z.number(),
});
export const data = z.object({
result: z.number(),
});
export const errCode = ["NO_LOGIN"] as const;
複数のインターフェースを定義した後、index.ts
で全てのインターフェースをエクスポートします。
// index.ts
export * as Add from "./add";
これで、すべてのルート情報を取得できます。
import * as routesWithoutPrefixObj from "./interface/index";
const routesWithoutPrefix = Object.values(
routesWithoutPrefixObj
) as ValueOfObjectArray<typeof routesWithoutPrefixObj>;
公共型の定義#
一般的な返却構造、公共のプレフィックス、および未知のエラーコードを定義します。私たちはインターフェースが直接データを返すのではなく、エラーコードとデータを含むオブジェクトを返します。
const routesWithoutPrefix = Object.values(
routesWithoutPrefixObj
) as ValueOfObjectArray<typeof routesWithoutPrefixObj>;
export const prefix = "/api";
export type Prefix = typeof prefix;
export const unknownError = "UNKNOWN_ERROR" as const;
export type OutputType<T, Err extends readonly string[]> =
| {
err: ArrayToUnion<Err> | typeof unknownError;
data: null;
}
| {
err: null;
data: T;
};
プレフィックス付きの実際のルートを計算します。
export const routes = routesWithoutPrefix.map((r) => {
return {
...r,
path: `${prefix}${r.path}`,
};
}) as unknown as AddPathPrefixForRoutes<typeof routesWithoutPrefix>;
ルート構造の変換#
ここまでで、すべてのルートオブジェクトで構成された配列を取得しましたが、これは型安全な fetch を実現するには便利ではありません。この配列をオブジェクトに変換する必要があります。このオブジェクトのキーはルートのリクエストパスで、値はルートのその他の情報です。こうすることで、fetch を呼び出すときに、正しい入力型と出力型を得ることができ、より良い補完体験が得られます。
type RouteItem = {
readonly path: string;
input: z.AnyZodObject;
data: z.AnyZodObject;
errCode: readonly string[];
};
type Helper<T> = T extends RouteItem
? Record<
T["path"],
{
input: z.infer<T["input"]>;
data: z.infer<T["data"]>;
errCode: T["errCode"];
output: OutputType<z.infer<T["data"]>, T["errCode"]>;
}
>
: never;
export type DistributedHelper<T> = T extends RouteItem ? Helper<T> : never;
export type Route = UnionToIntersection<
DistributedHelper<ArrayToUnion<typeof routes>>
>;
これにより、すべてのルート情報を含む最終的な型Route
を得ることができ、この型の大まかな構造は次のようになります。
type Route = Record<"/api/add", {
input: {
a: number;
b: number;
};
data: {
result: number;
};
errCode: readonly ["NO_LOGIN"];
output: OutputType<{
result: number;
}, readonly ["NO_LOGIN"]>;
}> & Record<"/api/dataSet/delDataSet", {
...;
}> & ... 13 more ... & Record<...>
fetch に型を追加#
fetch の最初の引数 path をkeyof Route
、2 番目の引数を対応するルートの入力型に制約するだけで、正しい出力型を得ることができます。実装を簡単にするために、すべての HTTP リクエストはPOST
メソッドを使用し、すべての入力パラメータはボディから渡されます。
export const myFetch = async <Path extends keyof Route>(
path: Path,
input: Route[Path]["input"]
) => {
try {
const res = await fetch(path, {
method: "POST",
headers: headers,
body: input instanceof FormData ? input : JSON.stringify(input),
});
const data = await (res.json() as Promise<Route[Path]["output"]>);
if (data.err) {
throw new CustomError(data.err);
}
return data.data as Route[Path]["data"];
} catch (err) {
if (err instanceof CustomError) {
throw err;
}
if (err instanceof Error) {
throw new CustomError(err.message);
}
throw new CustomError(unknownError);
}
};
class CustomError<T extends string> extends Error {
errorCode: T;
constructor(msg: string) {
super(msg);
Object.setPrototypeOf(this, CustomError.prototype);
this.errorCode = msg as T;
}
}
素晴らしいですね、使用効果を見てみましょう。
最後#
この記事はここで終了しますが、あなた自身のニーズに応じて、以下のことを行うことができます:
- それを useSWR と組み合わせることで、各ネットワークインターフェースのために関数を個別にラップする必要がなくなります
- fetch 内でより多くのロジックをラップすることで、ログインに必要なトークンを自動的に持ち運び、ログイン状態が期限切れになったときに自動的にルートを遮断します
- ファイルのアップロードやダウンロードなど、非 JSON インタラクションのインターフェースを個別に処理します
- あなたのバックエンドを説得して、ts を使ってインターフェースを作成させることで、これらのルート定義を手動で書く必要がなくなり、より安全になります
もし興味があれば、以下は記事で展開されていないが使用されている型計算のいくつかです。もちろん、この実装が最良であるとは限りませんので、改善のアイデアがあればぜひお話ししましょう。ありがとうございます。
export type ValueOfObjectArray<
T,
RestKey extends unknown[] = UnionToTuple<keyof T>
> = RestKey extends []
? []
: RestKey extends [infer First, ...infer Rest]
? First extends keyof T
? [T[First], ...ValueOfObjectArray<T, Rest>]
: never
: never;
// https://github.com/type-challenges/type-challenges/issues/2835
type LastUnion<T> = UnionToIntersection<
T extends any ? (x: T) => any : never
> extends (x: infer L) => any
? L
: never;
type UnionToTuple<T, Last = LastUnion<T>> = [T] extends [never]
? []
: [...UnionToTuple<Exclude<T, Last>>, Last];
type AddPrefix<T, P extends string = ""> = T extends RouteItem
? {
path: `${P}${T["path"]}`;
input: T["input"];
data: T["data"];
errCode: T["errCode"];
}
: never;
export type AddPrefixForArray<Arr> = Arr extends readonly []
? []
: Arr extends readonly [infer A, ...infer B]
? [AddPrefix<A, Prefix>, ...AddPrefixForArray<B>]
: never;
export type DistributedHelper<T> = T extends RouteItem ? Helper<T> : never;
export type ArrayToUnion<T extends readonly any[]> = T[number];
export type UnionToIntersection<U> = (
U extends any ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never;