TypeScript Compiler APIを使って型を中心に実装を自動生成する

この記事は、ドワンゴアドベントカレンダー2020の10日目の記事です。

qiita.com

はじめに

ベルリシア(@berlysia) という名前で活動しています。Webが好きです。ドワンゴでは、N予備校をはじめとする教育事業のWebフロントエンド開発をしています。

この記事では、Webフロントエンドチームの実際の開発で用いた、TypeScript Compiler APIを使っての型を中心とした実装の自動生成事例を紹介します。考察パートが中心で、コードはほぼ出てきません。

状況説明

管理画面の開発です。検索と結果一覧とCRUD操作が中心で、多くの画面が定型的な実装です。画面数が多いため、いかにこうした定型的な画面を効率よく開発して複雑なところに注力するかが、この開発を成功させるカギとなります。また、人員的にもある程度の並列性を確保している状態です。

APIはOpenAPIで仕様が提供され、OpenAPI Generatorでクライアント実装と型を生成しています。これはあまり重要ではありませんが、TypeScriptで開発していてReactを使っており、フォーム管理にはReact Hook Form、UIコンポーネントにMaterial UIを利用しています。

定型的な実装

検索フォームを例に、やることはこのような感じです、単純ですね:

  1. 検索結果はその条件を保存することができるべきです。URLのクエリ文字列に情報を持つことにします*1
  2. 検索条件はフォームで各種値を設定します。
  3. フォームで入力した値をAPIクライアントに渡して、リクエストを行います。

いくつか実装してみると気付く制約

3つの要素を叶えるにあたり、このどれもがAPIクライアントに渡すリクエストパラメータであることは、容易に気付けます。しかし、単純にそれぞれから得た値を取りまわすことはできませんでした。各所の値の扱いに、個別の制約が存在していたのです。

クエリ文字列の制約

一般的なクエリ文字列です。

クエリ文字列から検索パラメータを復元する場合、クエリ文字列は好き勝手に破壊される可能性があるため、無邪気にパース結果を採用することはできません。たとえば数値や日付のようにパースに失敗する値や、enumで未定義な入力が与えられた場合に、それらが適切に除去されたりすべきです。真偽値であれば、何をtrueとするか、何をfalseとするかも整っていなければいけません。

パースに失敗しうる値が検索条件として必須のものだった場合には、たとえばデフォルト値を与えてやるとか、検索できないと警告を出すとか、何かしらのあしらいが必要で、それは各所で要求が異なるでしょう。

フォームの制約

フォーム末端の実装には Material UI を、フォームの状態管理には React Hook Form を用いました。

Selectコンポーネントや数値入力コンポーネントの初期状態を未指定にするために、定義域にない空文字列を指定することがあり、うかつに書くとフォーム状態に空文字列が混入してしまいます。

数値入力コンポーネントではとくに、React Hook Formを用いて値を取り出すと文字列として得られるため、とくに値の変換を書いてやる必要がありました。

制約と状況に伴う困難

実装すべきものから考えて、次の3つの手順が必要になります:

  1. クエリ文字列から検索パラメータを復元する
  2. 検索パラメータを検索フォームに反映する
  3. 検索フォームの値から検索パラメータを構築する

レビューがつらい

こうした処理を各検索画面ごとに実装する必要があります。APIリクエストパラメータは画面ごとにもちろん異なりますから、この値に対してはこういう処理、という知識を開発者が持つ必要がありました。

レビューで担保せざるを得ないと考えていたのですが、すぐにハードであることがわかりました。開発者を多くして高度に並列性を保つために、各画面単位で閉じる限りはある程度の実装パターンを各開発者に任せていたのですが、微妙な実装パターンの差異がうまれて、レビュアーの負荷をより高くしていたのです。

開発者もつらい

実装パターンを合わせればいいのだ、といっても合わせる先を定義して、その指針に従ってひとつひとつ手書きするのでは、あまり人間である意味がないように思えます。ReduxのActionを手書きで実装するような虚無感との戦いです。

リクエストパラメータのどのキーにはどの操作をすべき、とユーティリティを書くことはできたでしょうが、同じnumber型でも、optionalだったりnullableだったりの組み合わせとそれぞれの処理ごとに微妙に考慮すべきことが異なっており、ユーティリティも膨大なものになることが予想できました。テストを書くとしても、各値ごとに定型的なテストを書くことになり、開発者を無駄に疲れさせるだけです。

ところで、3つの手順を適用する順序もまた一定の方がよいでしょう。各値の型に対して、やるべきことも一定です。すると、この面倒な値の変換操作というのは、型を中心に自動生成できるのではないでしょうか?

単純化

フォームの入出力の型を整える

フォーム周りをよく観察した結果、「入力する値」「出力する値」の型が合っていないことに気付きます。React Hook Formに与える値とその型に、フォームから取得できる値の構造が一致していなかったのです。

これに対処するために、React Hook FormのControllerと、Material UIのフォーム系コンポーネントを用いて、入出力の値の型を厳格にする末端コンポーネントを、必要なすべての値種別に対して作成しました。このおかげで、未入力状態を表現するための空文字列からも解放されたうえ、NaNやInvalid Dateを気にすることなくフォームの値を利用できるようになりました。

各値の手順ごとのあしらいを全列挙

クエリ文字列からの復元結果、フォームの値として表現可能な範囲、APIリクエストパラメータの値、それぞれに制約があることは上で述べました。自動生成をする前提に立てば、膨大なパターンのユーティリティであっても、使い方を間違ったりはしません。各値の型に対して、各手順ごとに必要な変換操作を、すべてユーティリティとして実装しました。

f:id:berlysia:20201211013417p:plain
考察結果の一部

ここまで来れば、あとは型を見て、コードを生成するだけです。

できたもの

TypeScript Compiler APIは細かいドキュメントが存在しませんが、簡単な利用例と型定義を手掛かりに、シンボルから型情報を読み取って、必要な型や関数を自動生成することができました。

次のような入力に対して:

// 何かしらの型を指定するためのマーカーを定義しておき
declare function Mark<T>(): void;

// 対象となる型のシンボルが見える状態
type RequestParams = {
  optionalNumber?: number;
  optionalString?: string;
  requiredBoolean: boolean;
};

Mark<RequestParams>();

次のような出力が得られます:

// 何かしらの型を指定するためのマーカーを定義しておき
declare function Mark<T>(): void;

// 対象となる型のシンボルが見える状態
type RequestParams = {
  optionalNumber?: number;
  optionalString?: string;
  requiredBoolean: boolean;
};

import {
  acceptOptionalNumber,
  acceptOptionalString,
  acceptBoolean,
  Ensurer,
  formifyOptionalNumber,
  formifyOptionalString,
  formifyBoolean,
  paramifyOptionalNumber,
  paramifyOptionalString,
  paramifyBoolean,
} from "./utils/utilsForGenerated";

/**
 * クエリ文字列のパース結果からAPIクライアントのパラメータ構造を得る関数
 */
export const acceptRequestParams: Ensurer<RequestParams> = (
  parsed
) => ({
  optionalNumber: acceptOptionalNumber(parsed.optionalNumber),
  optionalString: acceptOptionalString(parsed.optionalString),
  requiredBoolean: acceptBoolean(parsed.requiredBoolean) ?? false,
});

/**
 * useForm の defaultValues に渡し、かつフォームから得られる値の構造
 */
export type FormValues = {
  optionalNumber: number | null;
  optionalString: string;
  requiredBoolean: boolean;
};

/**
 * APIクライアントのパラメータからフォームに渡す構造に変換する関数
 */
export const toFormValues = (
  params: RequestParams
): FormValues => ({
  optionalNumber: formifyOptionalNumber(params.optionalNumber),
  optionalString: formifyOptionalString(params.optionalString),
  requiredBoolean: formifyBoolean(params.requiredBoolean) ?? false,
});

/**
 * フォームの初期値
 */
export const defaultValues: FormValues = {
  optionalNumber: null,
  optionalString: "",
  requiredBoolean: false,
};

/**
 * フォームの結果からAPIクライアントのパラメータに変換する
 */
export const toRequestParams = (
  formValues: FormValues
): RequestParams=> ({
  optionalNumber: paramifyOptionalNumber(formValues.optionalNumber),
  optionalString: paramifyOptionalString(formValues.optionalString),
  requiredBoolean: paramifyBoolean(formValues.requiredBoolean),
});

各種ユーティリティに操作を閉じ込めたので、生成するコードはユーティリティをどう使うかに集中できます。

生成したいコードをASTレベルで組み立てる際には、 ts-factory-code-generator-generator を利用しました。

疑問

なぜコードを自動生成したのか

TypeScriptの型しか頼りになるものがなく、実行時に使える構造情報がなかった点は、コードの自動生成を選んだ理由のひとつでした。 OpenAPIの仕様をそのままJSON Schemaとしてロードするようなことも考えられましたが、実行時処理の場合は全てのパターンを書き連ねておく必要があり、また細かい例外に対する吸収も実装内で行う必要が出てきます。何かの修正をする場合も、各利用箇所における影響をすべて精査しなければなりません。

コードを自動生成し、開発者による微修正を認めることにより、次のような利点がありました:

  • 細かい例外への対処をその場限りで済ませやすく、大きな共通処理と比較して各モジュールが単純で済む
  • 各モジュールの作り方と使い方が確定するため実装パターンが強制され、新規開発者もメンタルモデルの構築がスムーズになり、レビューのコストが下がる
  • 型からだけでは判断できない情報は型エラーが出るようなコードを吐くことができ、開発者が考えるべき箇所を限定でき、開発者の負荷が下がる

フォーム自体はどうしたか

外観の要求が少々複雑かつ安定していなかったため、自動生成は行いませんでした。こうした複雑な生成のためのタネになる入力は、しばしばJSONのうえに言語を作るような複雑さが生じがちです。

一応事例として react-jsonschema-form を調査しましたが、仮に作るとしたら自前で実装したでしょう。

これはメンテできるのか

TypeScript Compiler API はその資料にもあるように、APIが安定していません。APIがdeprecatedとなる場合にも移行期間がありますから、そこは修正しやすい実装を保って、当面は動くように付き合い続ける必要があります。

誰でも手を加えることができるように、TypeScript Compiler APIに関する簡単な勉強会も開きました。幸いにも開発の苦しみはメンバー全員が共有しており、自動生成するようになってからの開発の快適さが勝ったようでした。

いくらかのリスクは想像できましたが、開発の背景から動く状態を作ることの優先度が高かったこと、しかし動けばよいコードを残すことは絶対に看過できなかったことから、一定の規約に従ったコードが残っていることを選びました。個別のパーツは難しいことをやらないことに注力し、その組み合わせ方をガイドしているだけなので、自動生成し続けられなくても手書きすることは容易にできます。自動生成される事実ももちろんうれしいですが、多くの画面の実装で勝手にメンタルモデルが統一されることに、大きな価値があるのです。

おわりに

考察のもと、限られた範囲ではありますが、TypeScript Compiler APIを用いて型を中心にいくらかの実装を自動生成したことを紹介しました。 管理画面という性質もあり、みなさまの手に触れられることはない箇所の話題にはなりますが、いち事例としてご笑納ください。

ドワンゴの教育事業のWebフロントエンド開発チームでは、手段を選ばないWebフロントエンドエンジニアを募集しています。

blog.nnn.dev

*1:ページネーション状態等も含んでいますが、ここでは省略