Tailwind.css + classNames() を型安全に扱う TypeScript Language Service Plugin を書いた

名前は irontail です。出オチです。

モチベ

業務で Tailwind.css を使う機会が増えてきました。

自分のチームでも少しづつ使ってますが、他にも社内の古いプロジェクトでン年前の CSS で消耗している人々に助け舟として勧めたりしています。

記述量が少ないとかより、過去の CSS を消し去って低いコストでまともにするツールとして好んでいる面があり、React プロジェクトでの CSS Modules からの移行先として、個人的には styled-components よりも好んで選択しています( React 非依存なのも知見の共有しやすさの面から好ましく感じています )。

ただ、文字列クラス名を React コンポーネントに書くことについては思うところもあって、型安全性の面では完全に退行しとるな〜という気持ちもありました。補完については VSCode ほかエディタ向けの拡張で対処できますが、typo や config の変更に強いツールが欲しいとは常々思ってました(新規開発でなく、上で述べたような移行先としての選択なのでなおさら)。

先行例とか

Tailwind を型安全にする試みには一応先行例があって*1、tailwindcss-classnames というそのままドンピシャなライブラリがあります。

これは Tailwind のデフォルト設定で提供されるクラス名を Union Type にした型定義ファイルを提供しています。また taiwind.config.js を上書きしているプロジェクト向けには型定義を生成する CLI ツールも提供しており、プロジェクトごとのカスタマイズにも対応しています。そして classnames の代わりに tailwindcss-classnames を import してコンポーネントで使えば、決まったクラスしか渡せない(渡さないとコンパイルエラーにできる)というわけです。 

当然私も最初はこっちを試したのですが、いくつか不満が出てきました。

補完が劣化する

tailwindcss-classnames は型定義を提供するので、当然入力時にクラス名の補完が効きます。一方これは TypeScript のただの文字列補完なので、VSCode 拡張機能にあったような色やスタイルのメタ情報が補完から消えます。これは明らかに退化しています。

補完については最初からエディタ拡張に任せる方がベストプラクティスなので、純粋にクラス名のチェックだけをやる仕組みが欲しいと思いました。

classNames などからの乗り換えが必要になる

tailwindcss-classnames を使っていると、tailwindcss-classnames が提供する classnames 関数を使うのが必須になります。

これは、元々 classnamesclsx などを利用しているプロジェクトに後から tailwind を入れるというケースで乗り換えコストが発生します。

そんなことしなくても、classNames() 関数の呼び出し箇所をいい感じにチェックする仕組みがあれば良さそうだと思いました。

tailwind.config.js を変更するたびに CLI を叩く、みたいのがそもそもダルい

もちろん watch していい感じに更新とか走らせればいいんですが、意識せずに変わってくれるならその方がよい……まで書いて気づいたんですが、多分自分は CLI を叩くのが嫌なのではなく、吐き出された型定義をリポジトリにコミットするのが嫌っぽいのだなと思いました( CSS Modules にもクラス名型定義を吐くやつとかあったけど面倒に感じることが多かった )。なのでコミットしなくて良いソリューションが欲しくなりました。

TypeScript Language Service Plugin として実装

TypeScript には Plugin という概念があります。プラグインと言っても Babel のようにコンパイル結果に介入するものではなく、エディタを介して編集中の補完やエラー検知に介入する仕組みです。

Writing a Language Service Plugin · microsoft/TypeScript Wiki · GitHub

著名なユースケースとしては Angular や GraphQL など、文字列テンプレート内で型エラーを出すために使われることが多いようですが、今回これを特定の名前の関数に渡る引数へのチェックに使っています(筆者は TypeScript Language Service Plugin の開発は初めてです)。

TS Plugin として作ることで TS こそ必須になりますが、特定のエディタに依存しないエラーチェックをリアルタイムに行う仕組みが提供できます。

しくみ

…と、こうしてできたのが冒頭の irontail ですが、詳しい仕組みを説明します。

Language Service には getSemanticDiagnostics という関数が生えています。これはファイル名を受け取って、そのファイル内で起こったエラーを配列で返します。早い話、これの返り値を上書きすることでカスタムの型エラーを作り出すことができます。

実際にカスタムのエラーを作る仕組みですが、だいたい以下の手順です

  1. プロジェクト内の tailwind.config.js を読む。あったらプロジェクト内の tailwind と、その tailwind が依存している postcss を require し、コンパイルする
  2. コンパイル結果の csspostcss-selector-parser でパースし、登場するクラスをすべて配列に詰めて返す
  3. getSemanticDiagnostics で渡ってきた対象の TS ファイルの AST を走査し、classNames/clsx/classnames という名前の関数呼び出しを発見する
  4. その引数が 2. で得られた配列に含まれないものだった場合、カスタムのエラーとして返す(そのファイルにもともとあった本来の型エラーとマージして返す)

2. のパースの仕組みはほぼ完全に VSCode 拡張のコード を真似しました(色とかのメタ情報を取る処理は余分なので削りたいなーと思ってます)。

これで冒頭の動画の仕組みが完成します。ちょっと余りにパースとコンパイルをしまくってるので案の定重いです。特に初回起動を速くしたいですね。

開発時にハマったポイント

Language Service も Compiler API もすべてが初めてで問題の切り分けに苦労したのですが、最も大変だったのはそもそも getSemanticDiagnostics が Promise を返せないことでした。要するにカスタムエラーを組み立てる処理内で非同期処理を使うことは基本的にできない、全てを同期的に書かないといけないということです(非同期処理を許す Issue は立ってるようですが、長いこと Open のままです)。

一方、postcss のコンパイル処理は非同期処理です。使っているプラグインがたまたますべて同期的で書かれていれば同期的なコンパイルも可能っぽいのですが、tailwindcss がそういう作りではないので困りました。最初にこれに気づいたとき、完全に詰んだと思って一度諦めかけました。

しょうがないので、postcss のコンパイル結果を一度どこかで覚えておき、次にエディタから呼び出しがあったときにその結果を返すという方針にしました(つまり、パースが終わってないときはカスタムエラーを返すの諦めてそこで中断)。

ただ postcss のパース結果は JS のクラスインスタンスで、JSONシリアライズしてどこかに保存というのも難しかったので、泣く泣くミュータブルなグローバル変数(というかクラスの readonly でない static フィールド)にパース結果をキャッシュする形になりました。Language Service で非同期がやりたいとき普通はどうするのが良いのでしょうか…。

展望

さきほど高速化という課題を挙げましたが、現状そもそも classNames や clsx に渡せる様々な引数の形式に現状対応できてません( classNames('flex', isHoge ? 'hoge' : 'fuga', { foo: isFoo }) ← こういうやつ。今は StringLiteral しかチェックしてません )。

この辺りをクリアしたらまぁまぁ実用性が出てくるのかなと思います。

まとめ

Tailwind.css を型安全に扱う仕組みを TypeScript Language Service Plugin として実装してみました。既存の tailwindcss-classnames と比べ、CLI で型定義を生成する必要がなく、元々使われている classNames や clsx の呼び出しにも対応できるツールです。

tailwind.config.js をモリモリ変更しながら使っている React プロジェクトなどでお役に立てれば幸いです。

まだ全然機能としては完全ではないので、Issue やフィードバックもありましたらぜひどうぞ

 

*1:twin.macro も型安全の先行例として挙げてもいいのかもしれませんが、流石に設計がやんちゃすぎてスルーしました。なので twin.macro については詳しくありません。