typed-api-specの機能
API定義
ApiEndpoints
DefineApiEndpointsを利用することで、API定義を行うことができます
code: api-endpoints.ts
export type PathMap = DefineApiEndpoints<{
"/users": {
get: {
resBody: {
// Status Codeごとにレスポンスを定義する
200: { userNames: string[] };
400: { errorMessage: string };
};
};
};
}>;
const main = async () => {
const fetchT = fetch as FetchT<"", PathMap>;
const res = await fetchT("/users");
if (!res.ok) {
// !res.okなので、eはStatus Code:400である{ errorMessage: string }に推論される
const e = await res.json();
return console.log(e.errorMessage);
}
// res.okなので、rはStatus Code:200である{ userNames: string[] }に推論される
const r = await res.json();
console.log(r.userNames);
};
main();
Query Parameter, Path Variable, Bodyなども定義できます
code: api-endpoints-schema.ts
export type PathMap = DefineApiEndpoints<{
"/users/:userId": {
// HTTP Methodを指定する。get、post,put,deleteなど
get: {
// Query Parameter
query: { detail: boolean }
// Path Variable
params: { userId: string }
resBody: {...};
};
};
"/users": {
post: {
// HTTP Request Body
body: { userName: string }
resBody: { 200: { userNames: string[] } };
}
}
}>;
ZodApiEndpoints
ZodApiEndpointsをsatisfiesすることで、スキーマの型チェックを行うことができます
code: zod-api-endpoints.ts
export const pathMap = {
"/users": {
get: {
resBody: {
200: z.object({ userNames: z.string().array() }),
400: z.object({ errorMessage: z.string() }),
},
}
} satisfies ZodApiEndpoints;
anyZ
ZodApiEndpointsで純粋なTypeScriptの型定義を利用したい場合、anyZを利用することができます。
anyZを利用した場合、API定義としては指定した型を利用しつつ、バリデーション時は何も行いません
実装は単にanyZ = <T>() => z.any() as ZodType<T>としているだけです
code: zod-api-endpoints-anyz.ts
type UsersResponse = { userNames: string[] }
export const pathMap = {
"/users": {
get: {
resBody: {
200: anyZ<UsersResponse>(),
400: z.object({ errorMessage: z.string() }),
},
}
} satisfies ZodApiEndpoints;
fetch
型定義の付与
window.fetchにFetchTの型定義を適用することで、API定義を利用したより厳密な型チェックを実行できます
例えばhttps://example.com/base_path/とhttps://example.com/base_path/usersの2つのエンドポイントを持つ場合は以下のようにします
code: fetch.ts
export type PathMap = DefineApiEndpoints<{
"/": {...};
"/users": {...};
}>;
パスのチェック
ApiEndpointsで定義したパス以外を渡すと型エラーになります
code: fetch-path-check.ts
https://nota.gyazo.com/04af55a84f27296091f9953c491ad203
Status CodeによるResponseの絞り込み
res.okの場合、レスポンスは20XのステータスコードであるレスポンスのUnion型になります
!res.okの場合、レスポンスは20X以外のステータスコードであるレスポンスのUnion型になります
switch(res.status)を利用することで、より厳密なレスポンス型の絞り込みが可能です
code: api-endpoints.ts
const main = async () => {
const fetchT = fetch as FetchT<"", PathMap>;
const res = await fetchT("/users");
switch(res.status): {
case 200:
// rはStatus Code:200である{ userNames: string[] }に推論される
const r = await res.json();
console.log(r.userNames);
case 400:
// eはStatus Code:400である{ errorMessage: string }に推論される
const e = await res.json();
return console.log(e.errorMessage);
}
};
main();
https://nota.gyazo.com/abaf8ee3f82c8a31d1147fdf64e73e4d
body
bodyとしてjsonを受け取るエンドポイントでは、そのjsonのスキーマをAPI定義として表現することができます
fetch側でbodyに値を渡す際には、typed-api-spec/jsonで提供しているJSON型を利用してください
code: post-body.ts
export type PathMap = DefineApiEndpoints<{
"/users": {
post: {
body: { userName: string }
res: { 200: { userNames: string[] } };
};
};
}>;
import JSONT from @mpppk/typed-api-spec/json
const JSONT = JSON as JSONT
const fetchT = fetch as FetchT<"", PathMap>;
const res = await fetchT("/users", {
method: 'post',
// Use JSONT instead of JSON
body: JSONT.stringify({userName: "niboshi"})
});
https://nota.gyazo.com/4c6c94ee2ae31cbe3163cc2852e29f91
Express
code: express.ts
const pathMap = {
"/users": {
get: {
query: z.object({ page: z.string() }),
resBody: {
200: z.object({ userNames: z.string().array() }),
400: z.object({ errorMessage: z.string() }),
},
}
} satisfies ZodApiEndpoints;
const app = express();
app.use(express.json());
const wApp = typed(pathMap, app);
wApp.get("/users", (req, res) => {
// API定義したquery parameterに基づいたバリデーションを実行
const r = res.locals.validate(req).query();
if (r.success) {
// res.jsonはStatus Code:200のレスポンス型のみを受け入れる
res.json({ userNames: [page${r.data.page}#user1] });
} else {
// res.status(400).jsonはStatus Code:400のレスポンス型のみを受け入れる
res.status(400).json({ errorMessage: r.error.toString() });
}
});
typed
const wApp = typed(pathMap, express());
与えられたExpress Routerに対して以下の2つを行うメソッドです
API定義に基づいたバリデーションを実行するreq.locals.validateを追加するミドルウェアを適用
Express Routerに、API定義を利用したより厳密な型を付与
戻り値のRouterは、引数として与えたExpress Routerと型定義以外は同じものです
asAsync
Expressは、非同期処理中に発生したPromise.rejectや例外のthrowはユーザが明示的にハンドリングする必要があります
asAsyncはこのような例外ハンドリングを自動的に行うExpress Routerのラッパーを返します
code: as-async.ts
const wApp = asAsync(typed(pathMap, express()));
wApp.get("/users", async (req, res) => {
// asAsyncによってラップされたExpress Routerは、asyncハンドラからthrowされた例外をExpressエラーハンドラへ渡してくれる
throw Error()
});
Status CodeによるResponseの絞り込み
上記コード例で紹介したように、typedメソッドで型付けされたExpress Routerでは、res.status(400)のように指定したステータスコードに応じて、レスポンスの型チェックを行います
Handler型
typedメソッドで型付けされたExpress Routerに直接ハンドラを定義することで、適切な型推論が行われます
Handlers型を利用することで、別途定義することが可能です
code: handlers.ts
type Handlers = ToHandlers<PathMap>
export const getUsers: Handlers'/users''get' = (req, res) => {... } Virtually zero-runtime client validation
typed-api-specはfetchに型定義を与えるだけなので、実行時には全く何も行いません(ゼロランタイム)
定義されたスキーマ通りにサーバがレスポンスを返していればクライアント側でのバリデーションは基本的に不要です
しかし、開発中はスキーマ定義とサーバの実装が食い違うこともあります
typed-api-spechでは、スキーマ定義と実装の整合性チェックのためのnewFetchメソッドを提供しています
newFetchは型安全なfetchを生成するためのメソッドです
引数によってクライアントバリデーションの有効/無効を切り替えることができます
例
code: github.ts
export const Spec = {
"/repos/:owner/:repo/topics": {
get: {
responses: {
// ↓これが正しいスキーマ定義ですが、今回はわざと間違ったスキーマ定義を行います
// 200: { body: z.object({ names: z.string().array() }) },
200: { body: z.object({ noexistProps: z.string().array() }) },
400: { body: z.object({ message: z.string() }) },
},
},
},
} satisfies ZodApiEndpoints;
これはGitHubのTopicを取得するためのAPIをtyped-api-specで定義したものです
本来、200レスポンスのbodyにはnamesというプロパティが含まれるのですが、間違ってnoexistPropsを定義しています
これをnewFetchで生成したfetchで検出してみます
code: fetch.ts
// 第一引数はスキーマ定義を返す非同期関数。ここでは単にSpecを返す
// 第二引数はバリデーションを実行するかのboolean。ここではtrueを指定してバリデーションを実行する
const newFetch2 = newFetch( async () => Spec, true);
const fetchGitHub = await newFetch2<typeof GITHUB_API_ORIGIN>();
const response = await fetchGitHub(endpoint, {});
if (!response.ok) {
result.innerHTML = Error: ${response.status} ${response.statusText};
return;
}
const { noexistProps } = await response.json();
実行すると、zodによって例外が送出されます。これはnewFetchの第二引数にtrueを渡しているため、クライアントサイドでのバリデーションが実行されたからです
このようにnewFetchの第二引数にtrueを渡してクライアントバリデーションを行うと、スキーマ定義と実装の整合性をチェックできて便利です。しかし1つ問題があります。クライアントサイドバリデーションのためにzodをインポートしたため、ゼロランタイムではなくなってしまいました
https://gyazo.com/6e000c4b3e60b907086c188103ff7944
viteのvanilla-tsに上記のスキーマ定義とクライアントサイドバリデーションを追加した際のバンドルサイズ
全体の178.65KBのうち、150.34KBがzod
そこで、typed-api-specでは、Virtually zero-runtime client validationという仕組みを用意しています。viteでビルドしていると仮定して、先ほどのコードを以下のように書き換えます
code: fetch2.ts
// 第一引数はスキーマ定義を返す非同期関数。
// 第二引数はバリデーションを実行するかのboolean。
const newFetch2 = newFetch(() => import("./github.ts").then(m => m.Spec), import.meta.env.DEV);
// ...以降は同じ
こうすると、クライアントサイドバリデーションのためのコードは全く含まれなくなります
https://gyazo.com/01adf6e50e30fc29e3fa5f617627145b
全体で3.13KBになりました
newFetchのメソッド分のオーバーヘッドがあるため真のゼロランタイムではありませんが、十分に小さいサイズ(newFetchの実装が27B、呼び出し部分が17B、セミコロン入れて合計45B。いずれも非圧縮)であるため、Virtually zero-runtimeと名乗っても良いのではないでしょうか
45Bだとここにも書けるサイズなので書いてみると、具体的にはu=(t,r,i=fetch)=>async()=>i;l=u(),p=await l()が追加されるということです
この45Bも許せないということであれば、withValidationというさらにrawなメソッドを直接呼び出すことで、完全にゼロランタイムにすることもできます
仕組み
newFetchの内部では第二引数がtrueの時だけクライアントバリデーションに必要なモジュールをdynamic importします
import.meta.env.DEVは、viteがDEVモードの時はtrue, PRODUCTIONモードの時はtrueになります
rollupには、if(false) {}のような明らかに使用されないコードをeliminationする機能があります
これらが組み合わさった結果、viteがDEVモードの時だけクライアントバリデーションに必要なコードが読み込まれ、PRODモードの時は読み込まれないという挙動になります