前言#
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;