Open1

「ローコードSaaSのUXを向上させるためのTypeScript」のスピーカーノート

tarotaro

スライド
https://speakerdeck.com/taro28/rokodosaasnouxwoxiang-shang-saserutamenotypescript

はじめまして。
今日は「ローコードSaaSのUXを向上させるためのTypeScript」というタイトルで発表をさせていただきます。
よろしくお願いいたします。

まず自己紹介ですが大西太郎と申します。
ハンドルネームもtaroで、こちらの水色のアライグマのアイコンでTwitterなどをやっています。
仕事は株式会社ベースマキナでソフトウェアエンジニアとして働いていて、ローコードで管理画面を作成できるベースマキナというサービスを作っています。

ベースマキナは左の画像のようなフォームで、APIやデータベースの接続情報などを登録すると、右の画像のような、データを登録するフォームや一覧テーブルなどが簡単に作成できるサービスです。
今回はこのベースマキナでの開発で実際に行った、TypeScriptに関する事例をお話しさせていただければと思います。
それでは本題に入っていきます。

最初にタイトルの「UXを向上させるためのTypeScript」という部分で、今日何の話をしたいのかについて簡単に説明します。
まずベースマキナにはローコードサービスという特性上、一部ユーザーがJavaScriptで設定を書く機能があります。
例えば簡単な機能の例がこちらです。
これはSQLのselect文などで取得したデータの一覧を表示する際に、データを少しJavaScriptで加工した上で表示ができる機能です。
またもう少し複雑な機能だと、JavaScriptで複数の処理を組み合わせたワークフローを作成できる機能もあります。
これはユーザーを招待するワークフローで、ユーザーのデータを作成する処理を実行した後に、slackに通知する処理を実行しています。
このexecuteActionという関数で実行しているのは、アクションというユーザーが事前に作成したAPIやSQLなどを呼び出す1つ1つの処理で、それらを呼び出す順番や回数、条件などを柔軟に設定できるようにJavaScriptで設定が書けるようになっています。

最初の例のようなデータを少し加工する程度であれば、コードを書くのはそんなに難しくはないかもしれませんが、後者のワークフローを作成するようなコードだと、コードの入力や設定をサポートする機能がないと、設定に時間がかかってしまう可能性があります。
例えば、コードエディターで型が効くかどうかや、実行時にちゃんとバリデーションが効いてわかりやすいエラーメッセージが表示されるかなどです。
今日はこういったユーザーがJavaScriptを設定をする機能のユーザー体験を向上させるために、ベースマキナでどのようにTypeScriptの機能やライブラリを活用しているかをお話しできればと思っています。

ということで今日のセッションの流れです。

今日は大きく2つの話をさせていただきます。

1つ目は、先ほどお見せしたようなブラウザ上のJavaScriptのコードエディターの編集体験を向上させる話。
2つ目は、設定したJavaScriptのコードを実行した際のバリデーションの話です。

また各トピックは「エディターのこれを入力する時に型が効くようにしたい」みたいなやりたいことに対して、具体的にどういう方法で解決したのかという流れで話していきます。
ぜひ最初の「やりたいこと」の話のところで、みなさんならどうするかを考えてみていただけたら嬉しいです。
もし僕らと違う方法だったり、もっとよくできる点などがあったら、ぜひ懇親会などで教えていただければ幸いです。

では早速1つ目のエディターの話に入っていきます。

エディターの話の流れですが、まずユーザー体験を向上させた話の前に話の前提として、ユーザーがどんな形式のJavaScriptのコードを書く機能なのかと、エディターで使用しているMonaco Editorというライブラリについて簡単に紹介します。
その後TypeScriptの機能を使って、どのようにエディターのユーザー体験を向上させたかの話に入っていければなと思っています。
ということで、まずユーザーがどんなJavaScriptのコードを書く機能なのかを簡単にご紹介します。

今日話すのは先ほど少し複雑なコードを設定する例としてお見せした、JavaScriptでワークフローを作成できるJavaScriptアクションという機能です。
この機能でユーザーが設定するJavaScriptの形式は、ECMAScript modulesの形式です。

ユーザーはこのdefault exportする関数の中にワークフローの処理を書いていき、関数の戻り値がワークフローの実行結果として画面に表示されます。

じゃあ関数の引数は何かというと、第1引数はパラメーターと呼ばれるこのワークフローを実行する際に、左の画像のようなフォームで入力できる値です。
このパラメーターという値は、右の画像のようなフォームで、JavaScriptのコードを設定する際にその少し上で一緒に設定できます。

また1行目のように@basemachina/actionというpackageから、ベースマキナ側で用意した組み込みの関数などをimportして使用することもできます。

では次にこのブラウザ上のエディターで使用しているMonaco Editorというライブラリの紹介です。

Monaco EditorはMicrosoftが開発している、ブラウザ上で動作するコードエディターです。

公式のREADMEを引用すると、VSCodeのエディター部分を動かしているライブラリで、APIの使い方はVSCodeのドキュメントを見てくれと書いてあったりします。

普段僕らが使うサービスでもよく使われていて、TypeScript playgroundやGoogle CloudのBigQueryのエディターなどもMonaco Editorで作られています。

今回のセッションにおいては、型定義などをちゃんと書けばVSCodeと同様に、カーソルのホバーで型情報が表示されたり補完が効くエディターを使用しているよ、ということだけ踏まえていただければ大丈夫です。

前提が長くなりましたが、いよいよTypeScriptを使ってエディターのユーザー体験を向上させた話に入っていきます。

まず1つ目は先ほどのdefault exportされる関数の引数に型をつける話です。
具体的にはこの第1引数に型をつけて、ホバーで型情報を確認できるようにしたり補完を効かせたりするのがやりたいことです。
ここで第1引数の型はこちらの左の画像のようなフォームで設定する、パラメーターの設定内容に応じて動的に変わります。

例えば、nameという名前でテキストのパラメーターを作った場合は、右の画像のようにキーがname、値がstringのオブジェクトの型がつき、さらに左のフォームでageという名前で数値のパラメーターを追加した場合は、右の画像のようにキーがage、値がnumber | nullのプロパティが追加されるようにしたいです。

さてではこのJavaScriptの関数に対して、どのようにしたら型をつけることができると思いますか?

僕らはこんな感じでJSDocの型注釈を使うことにしました。
普段TypeScriptで開発をしている方なら、このようなJSDocをどこかで見たことがある人もいるのではないでしょうか?
これはよくフレームワークやライブラリのconfigファイルなどで使われている方法です。
たとえばNext.jsやViteの、javascriptのconfigファイルの例を見てみると、こんな感じでJSDocの型注釈が使われています。

JSDocではtypeタグってのを使うと、左のコードのようにTypeScriptと同様に型を指定することができます。
またtypeタグでは右のコードのように事前に定義した型を使って、型を指定することもできます。
さらに冒頭のJSDocのように別のpackageからimportしてきた型をtypeタグで指定することもできます。
このJSDocではdefault exportする関数に、@basemachina/actionからimportしたHandlerという型をつけています。

ここでimport元の@basemachina/actionには、通常のpackageと同様にモジュールの型を宣言した型定義ファイルを作っています。
そのためHandler型は、@basemachina/actionの型宣言の中で、普段型定義ファイルを書くのと同様に、関数の型を作って型エイリアスで名前をつけてexportするだけで、JavaScriptのコード内でimportして、JSDocのtypeタグで型をつけることができます。

ちなみにブラウザ上のエディターに型定義ファイルを追加する方法ですが、Monaco EditorではaddExtraLibというメソッドを使って型定義ファイルを追加できます。

ベースマキナではReactを使っているので、Reactのコードになるのですが、下の画像のようにuseMonacoというhookで、Monaco Editorのインスタンスにアクセスして、addExtraLibメソッドで

上のコードの型定義ファイルを追加すると、下の画像のようにdefault exportする関数の引数に型が効くようになります。

さてJavaScriptの関数にTypeScriptで型をつける方法がわかったので、次は左の画像のフォームの入力値から右のコードの型を動的に生成する部分です。

ここはそんなにおしゃれな技はなく、コードの一部分だけお見せしますが、文字列の結合などを使った泥臭い実装で、@basemachina/actionの型宣言とその中のHandler型を生成しています。

型の元となるフォームの状態に関しては、ベースマキナではReactのstateでフォームの状態を管理するformikを使っているので、Reactのhookで取得したフォームのstateから型定義ファイルの文字列を生成して、先ほどのaddExtraLibでMonaco Editorに同期しています。

これでdefault exportされるJavaScriptの関数に型をつける話は終わりで、最終的にはこんな感じで、フォームの設定内容に応じて動的に変化する型を、JavaScriptの関数につけることができました。

では次は組み込み関数に型をつける話です。
改めて組み込み関数ってのはこのように@basemachina/actionというpackageからimportできる組み込みの関数のことです。

この関数に型をつけて、型情報や補完で関数の使い方をわかりやすくするのがやりたいことです。
さきほどまでの話を踏まえると、これはほぼ自明な話かもしれないですがどうでしょうか。

はい、そうですね。
Handler型と同様に、@basemachina/actionの型宣言の中に組み込み関数の型を追加してexportするだけです。

するとこんな感じに、importする時に補完が効いたり、型をホバーで確認できるようになります。
次はユーザーが作成したデータを元に型をつける話です。
これはどういう話かというと、実は先ほど追加したexecuteActionという組み込み関数の型には、ユーザー体験的に足りない点があります。
それが引数の型の部分で、先ほど追加した型だと第1引数がstring、第2引数がRecord<string, any>となっているのですが、実際にはユーザーが作成したアクションのデータに基づいて、渡せる値に制限があります。
具体的には、まず第1引数にはアクションのIDまたは識別子という値を渡します。
IDはアクションを作成した時点で自動生成されるユニークなIDで、識別子は右下の画像のようなフォームでユーザーが任意に設定できるユニークな値です。
そのため型的にはstringでありつつも、存在しないIDや識別子を渡してしまうと、実行時に右の画像のように「そんなアクションは存在しないよ」というエラーが発生してしまいます。
そのためexecuteAction関数の第1引数を入力する際に、左の画像のように型が効くようにして、存在しないIDや識別子を入力してしまうのを防ぐのがここでやりたいことです。

またこのように型が効くと、コードを書いている途中に、呼び出すアクションのIDや識別子を確認する手間を減らせるというのも嬉しい点だったりします。
次に第2引数は呼び出すアクションに渡すパラメーターで、キーがパラメーターの名前、値がパラメーターの値となるオブジェクトを渡します。

パラメーターという用語が紛らわしくて恐縮ですが、今回はexecuteActionという関数に渡すパラメーターで、先ほどまで話していたdefault exportされる関数の引数は、ワークフロー自体のパラメータです。

例えば上の画像の設定のアクションを呼び出す場合は、下のコードのように、{ name: 'taro', age: 28 }のような、nameの型がstring、ageの型がnumber | null のオブジェクトを渡すことができます。

しかし誤って上のコードのように{ mame: 'taro', age: 28 }のような、パラメータの名前や値の型が異なる値を渡してしまうと、下の画像のように実行時にエラーになってしまいます。

そのため第2引数もこんな感じで第1引数にcreate-userという識別子を入力すると、{ name: string, age: number | null }のようなパラメーターの設定に対応したオブジェクトの型が推論されて、nと打つとnameが補完されるようにするのがやりたいことです。

またID/識別子と同様に型が効くと、呼び出すアクションにどんなパラメーターが設定されているかを、確認しにく手間を減らせるのも嬉しいポイントです。

さてこれはexecuteAction関数の型定義をどのようにすれば実現できると思いますか?
はい、ということで僕らはこんな型定義にしました。

ここでActionIdはすべてのアクションのID or 識別子のユニオン型です。

そしてActionArgumentsMapは、キーがアクションのID or 識別子、値がパラメーターの設定に対応したオブジェクトとなっている型です。

見ての通り、ActionIdがexecuteAction関数の型引数になっているので第1引数の値を入力すると、第2引数の型が絞り込まれるようになっています。

実際にこの型に書き換えてみると、第1引数の値によって絞り込まれた型推論が効くようになります。

あとはこの型をデータベースに保存されているアクションのデータから生成するだけです。
ベースマキナではバックエンドとの通信にGraphQL、データ取得兼状態管理ライブラリにはApollo Clientを使っています。

Apollo Clientではこんな感じにReactのhookで取得したデータのstateにアクセスできるので、あとはフォームのstateの時と同様に、泥臭く文字列の結合や正規表現などを使って型定義のコードを生成するだけです。

基本的にはこれでやりたいことは達成できています。

その他に実際は、パラメーターの名前、テキストや数値のようなパラメーターの種類以外にも、色んなパラメーターの設定に合わせて型を生成しています。

例えば、入力が必須のパラメーターであれば型も必須にして、それ以外はオプショナルにしたり、
必須でも初期値が設定されている場合は、オプショナルにしたり、またパラメーターが存在しない場合は、第2引数をnever型にしたり、といった感じで細かい条件分岐を加えています。

最終的にはこんな感じでユーザーが作成したアクションのデータに基づいて、バチバチに型が効くようになりました。

こういった自分が作成したデータに基づいて動的に型が効く編集体験は、ローコードサービス特有のものだと思っていて、個人的に結構面白いポイントだなと思っています。
これでエディターの話は終わりです。
セッションはまだ終わらず、この後まだバリデーションの話がありますので、もう少しだけお付き合いいただければと思います。

エディターの話のまとめとして、JSDocの型注釈でJavaScriptのコードにも型をつけられるよってことと、
ローコードサービスではユーザー体験のために、フォームやDBの値から泥臭く型を生成しているよってことを、知っていただけたら嬉しいです。

はい、ということで次はバリデーションの話に入っていきます。
具体的には先ほどまで話していた、組み込み関数の引数のバリデーションの話で、こちらの流れで進めていきます。

まずどんなバリデーションをなぜやりたいのかと組み込み関数がどのように実装されているかを話して、
その後組み込み関数のバリデーションを作りながら、どんなTypeScriptの機能やライブラリを使っているかを話していきます。ではまず組み込み関数に対して、どんなバリデーションをなぜやりたいのかの話です。
先ほどまでエディター上で組み込み関数に型をつける話をしていたのですが、あくまで実行するのはJavaScriptです。

そのため組み込み関数の引数には、上の画像のように型と異なる値を渡して実行することができてしまいます。

なのでバリデーションでやりたいことは、実行時に引数の値が型と一致しているかを検証して、型と合わない場合は下の画像のようなエラーメッセージを返せるようにすることです。

では具体的なバリデーションの話に入る前に、そもそもユーザーが設定したJavaScriptのコードとその中の組み込み関数が、どのように実行されているかを簡単に紹介をします。

全体的には3つのサービスで構成されています。
まずユーザーが操作するWebのフロントエンド、次がメインのバックエンドサーバー、最後がJavaScriptのコードを実行するためのNode.jsのサーバーです。

実行の流れ的には、まず画面のフォームからワークフローを実行すると、webからメインのバックエンドにリクエストが送られます。

次にバックエンドはデータベースから設定されているJavaScriptのコードを取得して、Node.jsのサーバーにリクエストを送ります。

次にNode.jsのサーバーは受け取った、JavaScriptのコードの組み込み関数を提供す@basemachina/actionのモジュール解決などをした後に、default exportされた関数の実行します。

実行後、関数の戻り値をメインのバックエンドサーバーを経由にして、webに返して終了です。

ここで組み込み関数は、上のコードのように普通のTypeScriptの関数として実装していて、下のコードのような1つのオブジェクトに詰めてモジュールの解決に使用されます。

ではここからが本題組み込み関数のバリデーションの話です。

改めてやりたいことは、組み込み関数を実行する前に引数が型通りの値かどうかを検証するバリデーションです。

また実装面だと組み込み関数は複数あり今後も数が増えていくので、バリデーションの機構は共通化したいです。
そして組み込み関数を追加する際はできるだけバリデーションのことは意識せずに、ただのTypeScriptの関数と近い体験で実装できるようにしたいです。
さてどのようにバリデーションの機構を実装するのが良いと思いますか?

僕らはこんな感じの、組み込み関数とその引数をバリデーションするためのzodのスキーマを受け取り、組み込み関数の実行前にバリデーションを差し込むような、generateBuiltInFuncという関数を作りました。
この関数の詳細を話す前に、知っている人も多いかもしれませんがzodとは何かを簡単に説明します。

zodはフォームなどでよく使われるTypeScriptのバリデーションライブラリです。
zodでは右のようなコードでスキーマを書いて、値がスキーマと一致しているかのバリデーションができます。

zodの便利な点はこのようにバリデーション後の値の型を推論できることです。
このコードではzodのオブジェクトのスキーマから、z.inferという型でそのオブジェクト型を推論しています。

今回作成したgenerateBuiltInFuncという関数でも、このスキーマからの型推論を活用していて、引数に対応するタプル型のスキーマを渡すと、組み込み関数の引数の型が推論されるので、引数の型をスキーマで定義する点以外は、普通に関数を書く時と同様に、型が聞いた状態で組み込み関数を実装できます。

ではgenerateBuiltInFuncの中身を見ていきます。
こちらがgenerateBuitInFuncの内部実装でポイントは2つです。

まず組み込み関数の引数にzodのスキーマから推論された型を付けている点です。
先ほどzodの紹介で使用したz.inferでfuncの引数に型をつけています。

次が組み込み関数の引数の値を、実際の組み込み関数に渡して実行する前にzodのスキーマでバリデーションをしている点です。
型がunknownの配列だった引数が、zodのスキーマでバリデーションした後だと型がつき、funcに渡せるようになっていることがわかります。

見ての通り中身は意外とシンプルですが、これを使ってワークフローを実行するとこんな感じでバリデーションが効いて、エラーメッセージが表示されるようになります。
これで基本的には組み込み関数の引数が型通りの値かどうかをバリデーションできるようになったのですが、このままだとzodのエラーメッセージがそのまま返ってしまっていて、あまりにもわかりにくすぎるので、終盤にエラーメッセージをわかりやすくする方法を紹介します。

さてここからはこのバリデーション関数を色んな引数の形式に対応できるようにしていきます。
まずは可変長引数への対応です。

可変長引数とは、このassignGroupという関数のusersという引数のように同じ型の値を任意の数渡せる引数です。
これはzodのtupleを使ったことのある方であればすぐにわかるかもしれませんがどうでしょうか。

はい僕らは可変長引数を表現するために、zodのタプルのスキーマで使用できるrestメソッドを使いました。
restメソッドはこちらのコード例のように、tupleの残りの型を指定できるメソッドでまさに可変超引数という感じです。

restメソッドの戻り値もzodのタプルの型になるため、generateBuiltInFuncの実装は変更不要で、可変長引数に関してはrestメソッドを使うだけで対応できるようになりました。

次は省略可能な引数の場合です。
省略できる引数の例として、これまで何度も取り上げてきたexecuteAction関数の場合だと、実は第2引数が上のコードのように省略できます。

ただこのような最後の引数だけが省略できる場合だと、意味は異なってきますが、先ほど紹介した可変長引数として扱って、restメソッド設定しても良いかもしれません。

ではこのcreateActionJob関数ではどうでしょうか?
これはアクションをジョブで実行できる関数で、上のコードのように第2引数までexecuteActionと同様の引数を受け取って、さらに第3引数で、実行を予約する時間であるscheduledAtを含むオブジェクトを、オプショナルな引数として受け取れます。

この関数の場合は、第2引数と第3引数では型が異なるため、可変長引数と同様にrestメソッドを使うわけにはいきません。
さてこのような省略可能な引数を受け取れるようにするには、どのようなスキーマを渡せば良いでしょうか?

最初に思いつくのはoptional()を使う方法だと思います。ただzodのtupleだとoptionalでも値の省略ができず、実際に第3引数を省略して実行してみると、このように引数の長さが足りないというエラーになってしまいます。

そのため僕らはこのように、長さの異なる複数のスキーマを渡せるようにしました。

組み込み関数の型はこんな感じに複数の関数の型のユニオン型になっています。
ではgenerateBuiltInFuncの実装がどのように拡張されたかを見ていきます。

まず関数の全体像はこんな感じです。
だいぶ複雑になってきましたが、まずは引数の型の部分を見ていきます。

まずスキーマの型で組み込み関数に型をつける部分はこんな感じになっています。
肝はスキーマの配列を、複数の関数のユニオン型に変換するためのMapped Typesです。

Mapped Typesというのは、ユニオン型を使ってkey in ユニオン型のように書くと、オブジェクトのキーをユニオン型で制約できる機能です。

今回はこのMapped Typesを応用して、キーがスキーマの配列のindex、値が各スキーマに対応する組み込み関数、となるようなオブジェクト型を作り、その[number]を取ることで、スキーマの配列を関数のユニオン型に変換しています。

次にスキーマでバリデーションをする部分です。ここも少し複雑になっていて、まず実行時に渡された引数と同じ長さのスキーマを探します。

同じ長さのスキーマがあればそれでバリデーションします。
なければ異なる長さのスキーマの中で一番長いスキーマに対して、その長さに合わせた引数を適用しています。少し複雑な話でしたが、こんな感じで省略可能な引数に対応できるようになりました。
ちなみにもっとオーバロードっぽさのある、同じ長さのスキーマを複数渡せるようにする場合は、詳細は省略しますが、先ほどのバリデーションを適用する部分で、長さが同じtupleのスキーマをfindしていた部分をfilterに変更してこんな感じの処理を書くと、このように第1引数の値の型によって、第2引数に渡せる値の型を制限できるようになります。

ただこの場合、型的にはオーバーロードになるため、型推論部分も変更が必要です。
具体的には先ほどのユニオン型だった部分を、こんな型でインターセクション型にする必要があるのですが、省略可能な引数の場合との共存が難しく、まだオーバーロードは必要になっていないので使っていません。

ここまででこのgenerateFuncという関数を様々な引数の形式に対応させていく話は終わりで、仕上げとしてエラーメッセージをわかりやすくしていきます。

冒頭でも述べた通りこのままだとzodのエラーが生で返っている状態で、とてもUXを向上させるための話には適しません。

そのためこんな感じにエラーメッセージをわかりやすくしていきます。

まずはエラーメッセージの日本語化です。
素朴な方法としてzodではこんな感じにスキーマを定義するメソッドの引数でエラーメッセージを設定できるため、各メソッドにエラーメッセージを設定していくという方法があります。

ただ僕らの使い方の場合だと、すべてのメソッドの全てのエラータイプごとにメッセージを渡す必要があり、あまり現実的ではありません。

そのためベースマキナではaijiさんが作成された、zodのエラーメッセージを各言語に翻訳できる、zod インターナショナリゼーションというライブラリを使用しています。

これを導入するとzodの各エラーメッセージがこんな感じに日本語になります。

さらにエラーメッセージの内容も、javascriptのオブジェクトを使って簡単に上書きできます。
余談ですがsatisfiesは本当に便利ですよね、大好きなTSの機能です。

話を戻して、次はエラーが引数のどこで発生したのかをわかりやすくする部分です。
zodでは発生したエラーのオブジェクトのpathで、エラーがどこで発生したのがわかるようになっています。

そのためpathを使ってエラーメッセージを整形するのですが、ここは特におしゃれな方法はなく泥臭く文字列の結合で成形しています。
さてこれであとは関数名などのメッセージをつけてあげたら完成です!

これでバリデーションの話は終わりです。

最終的にはJavaScriptの関数の引数が型通りの値がどうかをバリデーションして、わかりやすいエラーメッセージを返すことができるようになりました。
またzodとTypeScriptの柔軟の型の表現力のおかげで、実装面では普通にTypeScriptの関数を実装するのとかなり近い体験で、組み込み関数を実装できるようになったかなと思います。

では最後にトーク全体のまとめです。

ベースマキナではユーザーがJavaScriptで設定を書く機能があり、エディターやバリデーションなどでTypeScriptをユーザー体験のために活用している例がいろいろあります。

どちらも最終的には文字列の結合などで型やエラーメッセージを泥臭く生成している部分もありますが、
それも含めてローコードサービス特有のTypeScriptを使った開発の面白さの1つかなと思います。

また何よりユーザーがJavaScriptを設定する機能を、TypeScriptを使って作るのは、1エンジニアとしてとても面白い開発だと思っています。

ということで今回の発表でTypeScriptを使う仕事の1つとして、ローコードサービスの開発の面白さが少しでも伝われば幸いです。
このあとはブースにいるので、ぜひ今日のトークに関することでも、それ以外でも構いませんので色んなTypeScriptの話ができたら嬉しいです。

以上です。ご清聴ありがとうございました!