エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

TypeScript でメソッドチェーンしたら推論される引数が増えていくやつ

こんにちは、エムスリーエンジニアリンググループ Unit5 (Consumer) チームの園田です。

今回は大きな実装ではなく、TypeScript のちょっとしたテクニックを Next の API ルートを題材に書いてみます。 想定読者は TypeScript 初心者の方です。TypeScript 強者の方はどうぞ温かい目で見てください。

はじめに

まずは何を作ったのかご覧ください。

モチベーション

Next.js をご存知ない方はピンと来ないと思いますが、Next では API ハンドラ関数(Java や Rails でいう Controller メソッド)のシグネチャは以下のようになっています(app router を使わない前提)。

type NextApiHandler = (req: NextApiRequest, res: NextApiResponse) => any | Promise<any>

ですので、例えばバリデーションが必要な API だとして、普通に API ハンドラでバリデーションロジックを書くとこんな感じです。

const postSchema = z.object({ title: z.string(), contents: z.string() });

const createPost: NextApiHandler = async (req: NextApiRequest, res: NextApiResponse) => {
    try {
        const postData = await postSchema.parseAsync(req.body);
        const { data } = await axios.post('/api/post', postData);
        return res.json(data);
    } catch (e: unknown) {
        if (e instanceof ZodError) {
            return res.status(400).json(e);
        }
        throw e;
    }
}

export default createPost

実際には POST 以外の HTTP メソッドを禁止したいのでメソッドの判定もします。

const createPost: NextApiHandler = async (req: NextApiRequest, res: NextApiResponse) => {
    if (req.method !== 'POST') return res.status(405).end();
    // ...
}

さらに認可が必要な場合は以下のようなコードになるでしょう。

const createPost: NextApiHandler = async (req: NextApiRequest, res: NextApiResponse) => {
    // メソッド判定
    if (req.method !== 'POST') return res.status(405).end();

    // 認可制御
    const session = await getSession(req);

    if (!(await isAuthorized(session, { resource: 'post', action: 'create' }))) {
        return res.status(403).json({ message: 'Forbidden' });
    }

    const headers = { Authorization: `Bearer ${session.token}` };

    try {
        // リクエストボディのバリデーション
        const postData = await postSchema.parseAsync(req.body);

        // ここまできてようやく API 実行
        const { data } = await axios.post('/api/post', postData, { headers });

        return res.json(data);
    } catch (e: unknown) {
        if (e instanceof ZodError) {
            return res.status(400).json(e);
        }
        throw e;
    }
}

大したコード量ではないですが、このコードを API ルートごとにいくつも書くとなると、事前検証の処理やエラー処理を書き漏らしたり間違えたりしそうですね(いわゆるボイラープレートコードというやつ?)。

なので、以下のようにしたかったのです。より宣言的でセマンティックになり、コードの可読性も上がっていると個人的には思います。

const createPost = apiHandlerFactory()
    .accept('POST')
    .authorizeFor({ resource: 'post', action: 'create' })
    .validationWith(postSchema)
    // 認可制御やバリデーションは handle 関数の中の関数実行前に完了している状態
    // また、エラー処理は handle 関数の中の関数を実行する際にまとめて実施
    .handle(async ({ $axios, $validData, res }) => {
        // $axios は Bearer ヘッダ設定済みの AxiosInstance
        // $validData は postSchema でバリデーション後の req.body
        const { data } = await $axios.post('/api/post', $validData);
        return res.json(data);
    });

実装のポイント

メソッドチェーンによるインターフェースを実現するためには以下の方法で実装すると実現できます。

  • handle 内の関数が呼び出されるまでは validator や認可対象などの設定をレキシカルスコープ内の state として保持しておく
  • handle 以外の関数が呼び出された際は引数を state に追加していく
  • 関数の戻り値に再帰型を使うことで、handleの引数に特定の値があることを表現する

ただ、これは結果的にこうなったという感じで、筆者は最初からこのやり方を思いついていたわけではありません。 ここに至るまでに何度もインターフェースの変更と実装を繰り返しました。

試行錯誤

関数で何度も囲む形式

まっさきに思い浮かんだインターフェースがこれです。

const createPost = withValidation(
    postSchema, 
    withAuthorized(
        { resource: 'post', action: 'create' },
        withMethods('POST', async ({req, res, $axios, $validData }) => {
            // ...
        })
    )
);

実装は簡単ですが、カッコ悪すぎて美学に反します。とても読みづらいです。

引数指定 + オーバーロード

引数を変えることでオーバーロードにより型推論を効かせるパターンです。@hono/zod-validator もこの形式です。

ちなみに、今回の発想自体は @hono/zod-validator からインスパイアされています。

このパターンはさらに引数の順番で決めるパターンとオブジェクトで渡すパターンがあります。

// 引数の順番
const createPost = createHandler(
    'POST',
    { resource: 'post', action: 'create' },
    postSchema,
    async ({ req, res, $axios, $validData }) => {
        // ... 
    },
);

// オブジェクトで渡す
const createPost = createHandler(
    {
        accept: 'POST',
        authSubject: { resource: 'post', action: 'create' },
        validator: postSchema,
    },
    async ({ req, res, $axios, $validData }) => {
        // ... 
    }
);

これでも実現できたのですが、オーバーロードの分岐が増えるたびにものすごく大変な思いをするので途中でやめました。

実際に辿った実装手順

このセクションは思考を順に追っていくため非常に長いです。型パズルの説明が不要な方は読み飛ばしてください。 ただ、型パズルが苦手な方はぜひ実際にコードを写経してみることをオススメします。

最終的なインターフェースは最初に示した通りメソッドチェーンです。 どのように実装したかというと、まずは必要な型を順々に定めていきました。

NextApiHandler はシグネチャが (req: NextApiRequest, res: NextApiResponse) なのですが、これだと TypeScript では引数の追加が非常にしづらいです。

apiHandlerFactory()
  .validationWith(postSchema)
  .handle(async (req, res, $validData) => {}); // <= async の後のカッコ内がオブジェクトじゃない

詳しくは解説しませんが、上のコードを実現するのは難易度が高すぎるので、

apiHandlerFactory()
  .validationWith(postSchema)
  .handle(async (context) => {}); // <= async の後のカッコ内がオブジェクト

こうなるようにします。

ですので、NextApiHandler の引数をオブジェクトで受け取る関数を用意して、それを NextApiHandler に変換することが重要です。以下のような型が必要なことがわかります。

type NextApiHandlerContext = { req: NextApiRequest, res: NextApiResponse };

// 最終的に実現したい Factory の型
type NextApiHandlerFactory = {
    // validationWith: (validator: ZodSchema<V>) => ??, // ここはまだ置いておく
    handle: (handler: (context: NextApiHandlerContext) => ReturnType<NextApiHandler>) => NextApiHandler,
};

読みづらいので handle 関数の引数を抽出します。

type NextApiHandlerContext = { req: NextApiRequest, res: NextApiResponse };

// handle 関数の引数の型
type NextApiContextualHandler = (context: NextApiHandlerContext) => ReturnType<NextApiHandler>;

// 最終的に実現したい Factory の型
type NextApiHandlerFactory = {
    // validationWith: (validator: ZodSchema<V>) => ??, // ここはまだ置いておく
    handle: (handler: NextApiContextualHandler) => NextApiHandler,
};

ここに、バリデーション済みのデータを受け取るための型を追加していきます。まず Context に $validData を追加した型を用意します。

type Validated<V, T extends NextApiHandlerContext = NextApiHandlerContext> = T & { $validData: V };

続いて、Factory の型をジェネリクス付きに修正し、validationWith 関数を追加します。

type NextApiContextualHandler<T extends NextApiHandlerContext = NextApiHandlerContext> = (context: T) => ReturnType<NextApiHandler>;

type NextApiHandlerFactory<T extends NextApiHandlerContext = NextApiHandlerContext> = {
    validationWith: <V>(validator: ZodSchema<V>) => NextApiHandlerFactory<Validated<V, T>>, // <= 再帰型
    handle: (handler: NextApiContextualHandler<T>) => NextApiHandler,
};

validationWith で Factory を返すのですが、ジェネリクスつきの再帰型を返しています。また、handle の引数型がジェネリクスになっています。 これにより、validationWith を呼び出した後は handle の引数に $validData が含まれることを推論させることができます。

型ができたら実装です。まずは定義した型を適用した NextApiHandlerFactory のファクトリ関数(ややこしい)を定義します。

export function apiHandlerFactory<T extends NextApiHandlerContext = NextApiHandlerContext>(): NextApiHandlerFactory<T> {
    return {
        validationWith: <V>(validator: ZodSchema<V>) => apiHandlerFactory<Validated<V, T>>(),
        handle: (handler) => (req, res) => handler({ req, res } as T),
    }
}

この時点でコンパイルエラーは出ないのですが、validator が使われていないため(設定によっては)未使用警告が出るはずです。 validationWith で渡された validator を保持するための state が必要です。state を再帰時にバケツリレーできるように引数にします。

type NextApiHandlerFactoryState<V> = {
    validator?: ZodSchema<V>,
};

export function apiHandlerFactory</* 総称型を追加 */ V, T extends NextApiHandlerContext = NextApiHandlerContext>(
    state: NextApiHandlerFactoryState<V> = {} // <= Factory のファクトリ関数に引数を追加
): NextApiHandlerFactory<T> {
    return {
        validationWith: <V>(validator: ZodSchema<V>) => 
            // state を上書きして再帰
            apiHandlerFactory<V, Validated<V, T>>({ ...state, validator }),
        handle: (handler) => (req, res) => handler({ req, res } as T),
    }
}

これで handle 関数内から validator にアクセスできるので、バリデーションをしたデータを context に追加します。

export function apiHandlerFactory<V, T extends NextApiHandlerContext = NextApiHandlerContext>(
    state: NextApiHandlerFactoryState<V> = {}
): NextApiHandlerFactory<T> {
    return {
        validationWith: <V>(validator: ZodSchema<V>) => 
            apiHandlerFactory<V, Validated<V, T>>({ ...state, validator }),
        handle: (handler) => async (req, res) => {
            let context = { req, res } as T;

            if (state.validator) {
                const $validData = await state.validator.parseAsync(req.body);
                // context を上書き
                context = { ...context, $validData };
            }

            return await handler(context);
        }
}

これで未使用警告も消えて validationWith の対応はほぼ完了しました。続いてエラー処理も追加します。

if (state.validator) {
    // const $validData = await state.validator.parseAsync(req.body);
    // return await handler({ ...context, $validData });
    const result = await state.validator.safeParseAsync(req.body);
    if (!result.success) {
        return res.status(400).json({ errors: result.error.issues });
    }
    context = { ...context, $validData: result.data };
}

これで validation については完成です。この段階で呼び出し元のコードを実装してみます。 handle だけ呼び出した場合と、validationWith を呼び出した場合とで引数の推論が異なる事がわかると思います。

handle だけ呼び出した場合は $validData なし
handle だけ呼び出した場合

validationWith を呼び出した場合は $validData が推論される
validationWith を呼び出した場合

同じ要領でメソッドの判定も追加します。まずは型を定義します。

// 追加
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type Accepted<M extends HttpMethod, T extends NextApiHandlerContext = NextApiHandlerContext> = T & { $method: M[] };

type NextApiHandlerFactory<T extends NextApiHandlerContext = NextApiHandlerContext> = {
    validationWith: <V>(validator: ZodSchema<V>) => NextApiHandlerFactory<Validated<V, T>>,
    // 追加
    accept: <M extends HttpMethod>(...methods: M[]) => NextApiHandlerFactory<Accepted<M, T>>,
    handle: (handler: NextApiContextualHandler<T>) => NextApiHandler,
};

この時点で accept がないというコンパイルエラーが出るので、コンパイルエラーを解消し、メソッド判定処理を実装します。

type NextApiHandlerFactoryState<V> = {
    validator?: ZodSchema<V>,
    // 追加
    methods?: HttpMethod[],
};

function apiHandlerFactory<V, T extends NextApiHandlerContext = NextApiHandlerContext>(
    state: NextApiHandlerFactoryState<V> = {}
): NextApiHandlerFactory<T> {
    return {
        validationWith: <V>(validator: ZodSchema<V>) => apiHandlerFactory<V, Validated<V, T>>({ ...state, validator }),
        // 追加
        accept: <M extends HttpMethod>(...methods: M[]) => apiHandlerFactory({ ...state, methods }),
        handle: (handler) => async (req, res) => {
            let context = { req, res } as T;

            // 追加
            if (state.methods && state.methods.length > 0) {
                const $method = (req.method || '').toUpperCase() as HttpMethod;
                if (!state.methods.includes($method)) {
                    return res.status(405).json({ message: `${$method} is not allowed.` });
                }
                context = { ...context, $method };
            }

            if (state.validator) {
                const result = await state.validator.safeParseAsync(req.body);
                if (!result.success) {
                    return res.status(400).json({ errors: result.error.issues });
                }
                context = { ...context, $validData: result.data };
            }

            return await handler(context);
        }
    }
}

さらに同じ要領で認可制御の処理も入れます。こちらはプロジェクト依存のコードが多いためここでは割愛します。

最終的なコード

最終的に出来上がったコードが以下となります。

import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
import type { ZodSchema } from "zod";

type NextApiHandlerContext = { req: NextApiRequest, res: NextApiResponse };

type Validated<V, T extends NextApiHandlerContext = NextApiHandlerContext> = T & { $validData: V };

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type Accepted<M extends HttpMethod, T extends NextApiHandlerContext = NextApiHandlerContext> = T & { $method: M[] };

type NextApiContextualHandler<T extends NextApiHandlerContext = NextApiHandlerContext> = (context: T) => ReturnType<NextApiHandler>;

type NextApiHandlerFactory<T extends NextApiHandlerContext = NextApiHandlerContext> = {
    validationWith: <V>(validator: ZodSchema<V>) => NextApiHandlerFactory<Validated<V, T>>,
    accept: <M extends HttpMethod>(...methods: M[]) => NextApiHandlerFactory<Accepted<M, T>>,
    handle: (handler: NextApiContextualHandler<T>) => NextApiHandler,
};

type NextApiHandlerFactoryState<V> = {
    validator?: ZodSchema<V>,
    methods?: HttpMethod[],
};

export function apiHandlerFactory<V, T extends NextApiHandlerContext = NextApiHandlerContext>(
    state: NextApiHandlerFactoryState<V> = {}
): NextApiHandlerFactory<T> {
    return {
        validationWith: <V>(validator: ZodSchema<V>) => apiHandlerFactory<V, Validated<V, T>>({ ...state, validator }),
        accept: <M extends HttpMethod>(...methods: M[]) => apiHandlerFactory({ ...state, methods }),
        handle: (handler) => async (req, res) => {
            let context = { req, res } as T;

            if (state.methods && state.methods.length > 0) {
                const $method = (req.method || '').toUpperCase() as HttpMethod;
                if (!state.methods.includes($method)) {
                    return res.status(405).json({ message: `${$method} is not allowed.` });
                }
                context = { ...context, $method };
            }

            if (state.validator) {
                const result = await state.validator.safeParseAsync(req.body);
                if (!result.success) {
                    return res.status(400).json({ errors: result.error.issues });
                }
                context = { ...context, $validData: result.data };
            }

            return await handler(context);
        }
    }
}

さて、本来ならこれで終わりなのですが、続きがあります。 しばらく運用をしていたら、一覧ページなどでページネーションのクエリパラメータを取得するコードが量産されていることに気が付きました。

const handler = apiHandlerFactory()
    .handle(async ({ req, res }) => {
        const { _start, _end, _order, _sort } = req.query;
        const params = {
            start: _start ? Number(_start) : 0,
            end: _end ? Number(_end) : 10,
            order: _order ? String(_order).toUpperCase() : 'ASC',
            sort: _sort,
        }
        const { data } = await axios.get('/api/users', { params });
        return res.json({ items: data })
    });

こちらは Refine という管理画面のページネーションパラメータなのですが、毎回取得するのも面倒なのでこれも組み込んでしまいます。

// データの変換は Zod に任せてしまうと楽ちん
const paginationSchema = z.object({
    _start: z.string().default('0').transform(Number).pipe(z.number()),
    _end: z.string().optional().transform(v => v ? Number(v) : undefined).pipe(z.number().optional()),
    _order: z.enum(['asc', 'desc']).default('asc').transform(v => v.toUpperCase()),
    _sort: z.string().default('id'),
});

// 新規型追加
type Pagination = { start: number, end?: number, order: 'ASC' | 'DESC', sort: string };
type Paginated<T extends NextApiHandlerContext = NextApiHandlerContext> = T & { $pagination: Pagination };

type NextApiHandlerFactory<T extends NextApiHandlerContext = NextApiHandlerContext> = {
    validationWith: <V>(validator: ZodSchema<V>) => NextApiHandlerFactory<Validated<V, T>>,
    accept: <M extends HttpMethod>(...methods: M[]) => NextApiHandlerFactory<Accepted<M, T>>,
    // 追加
    pagination: () => NextApiHandlerFactory<Paginated<T>>,
    handle: (handler: NextApiContextualHandler<T>) => NextApiHandler,
};

type NextApiHandlerFactoryState<V> = {
    validator?: ZodSchema<V>,
    methods?: HttpMethod[],
    // 追加
    pagination?: boolean,
};

export function apiHandlerFactory<V, T extends NextApiHandlerContext = NextApiHandlerContext>(
    state: NextApiHandlerFactoryState<V> = {}
): NextApiHandlerFactory<T> {
    return {
        validationWith: <V>(validator: ZodSchema<V>) => apiHandlerFactory<V, Validated<V, T>>({ ...state, validator }),
        accept: <M extends HttpMethod>(...methods: M[]) => apiHandlerFactory({ ...state, methods }),
        // 追加
        pagination: () => apiHandlerFactory({ ...state, pagination: true }),
        handle: (handler) => async (req, res) => {
            let context = { req, res } as T;

            if (state.methods && state.methods.length > 0) {
                const $method = (req.method || '').toUpperCase() as HttpMethod;
                if (!state.methods.includes($method)) {
                    return res.status(405).json({ message: `${$method} is not allowed.` });
                }
                context = { ...context, $method };
            }

            if (state.validator) {
                const result = await state.validator.safeParseAsync(req.body);
                if (!result.success) {
                    return res.status(400).json({ errors: result.error.issues });
                }
                context = { ...context, $validData: result.data };
            }

            // 追加
            if (state.pagination) {
                const result = await paginationSchema.safeParseAsync(req.query);
                if (result.success) {
                    const { _start, _end, _order, _sort } = result.data;
                    const $pagination: Pagination = { start: _start, order: _order, sort: _sort };
                    if (_end)  $pagination.end = _end
                    context = { ...context, $pagination };
                }
            }

            return await handler(context);
        }
    }
}

おわかりでしょうか? この対応、コードの追加のみで既存コードの修正は一切行っていないのです。 もちろん、呼び出し元にもなんら影響はありません。いいですね!

Class 構文での実装

Class 構文で実装すると以下のようになります。Class に慣れている人はこちらの方が理解しやすいかも。

export const apiHandlerFactory = () => new NextApiHandlerFactoryImpl()

class NextApiHandlerFactoryImpl<V, T extends NextApiHandlerContext = NextApiHandlerContext> implements NextApiHandlerFactory<T> {
    constructor(private readonly state: NextApiHandlerFactoryState<V> = {}) {}

    validationWith<V>(validator: ZodSchema<V>): NextApiHandlerFactory<Validated<V, T>> {
        return new NextApiHandlerFactoryImpl({ ...this.state, validator });
    }

    accept<M extends HttpMethod>(...methods: M[]): NextApiHandlerFactory<Accepted<M, T>> {
        return new NextApiHandlerFactoryImpl({ ...this.state, methods });
    }

    pagination(): NextApiHandlerFactory<Paginated<T>> {
        return new NextApiHandlerFactoryImpl({ ...this.state, pagination: true });
    }

    handle(handler: NextApiContextualHandler<T>): NextApiHandler {
        return async (req, res) => {
            let context = { req, res } as T;

            if (this.state.methods && this.state.methods.length > 0) {
                const $method = (req.method || '').toUpperCase() as HttpMethod;
                if (!this.state.methods.includes($method)) {
                    return res.status(405).json({ message: `${$method} is not allowed.` });
                }
                context = { ...context, $method };
            }

            if (this.state.validator) {
                const result = await this.state.validator.safeParseAsync(req.body);
                if (!result.success) {
                    return res.status(400).json({ errors: result.error.issues });
                }
                context = { ...context, $validData: result.data };
            }

            if (this.state.pagination) {
                const result = await paginationSchema.safeParseAsync(req.query);
                if (result.success) {
                    context = { ...context, $pagination: result.data };
                }
            }

            return await handler(context);
        }
    }
}

まとめ

こちら例として Next の API を用いていますが、Next に限らず TypeScript で同じように型を漸進的に変更していく場合に、「 ジェネリクス付きの再帰型 でまず型定義を行い、それに沿って実装していくといい」ということを憶えておくとどこかで役に立つかもしれません。

We are hiring

エムスリーでは TypeScript や Next に限らず、Vue / Nuxt や Svelte / Astro / Qwik など新しいフロントエンドにも挑戦可能です。フロントエンドに限らずバックエンドエンジニアや QA エンジニアも随時募集中です。軽く話を聞いてみるだけでも OK ですので、ぜひともカジュアル面談をお申し込みください!

jobs.m3.com