1855 文字
9 分
TypeScript はマクロの夢を見るか: Sweetener で始める構文拡張

みなさんは​パイプライン演算子が​欲しいですか?​私は​欲しいです.みなさんも​欲しいですよね.ええ,​そうです.みなさんは​パイプライン演算子を​求めています.

ですが​ TC39 の​ proposal-pipeline-operatorは​ Stage 2 から​一向に​動く​気配を​見せません.

これでは​困りましたね.​敬虔な​関数型言語信者のみなさんであれば,​パイプライン演算子の​ない​世界の​コードを​書いたり​読んだりするのが​辛いと​いう​ことを​知っているはずです.

現に​ TypeScript では​パイプライン演算子が​無いために,​こんな​ことを​しています.なんと​いう​ことでしょう.

// 内側から外側へ読む
sum(map([1, 2, 3], (n) => n * 2));
// あるいは pipe 関数で頑張る
pipe(
[1, 2, 3],
map((n) => n * 2),
sum,
);

かたや​お隣,とは​言い​難いですが​ Rust に​目を​向けると​ macro_rules!なる​マクロ展開が​サポートされているでは​ありませんか.​TypeScript にも​こういう​仕組みが​あれば​構文拡張も​できそうですね.

…え,​あるんですか?

ある​みたいですよ.それが​今回紹介する​ Sweetenerです.


Sweetener とは何か#

Sweetener は​マクロを​定義・​使用する​ための​独自構文 .stsから​ .tsを​生成する​ための​ライブラリ・ビルドツールです.

とりあえずインストールしましょう.

Terminal window
npm install --save-dev @sweetener/cli
npx sweetener init

試しに,​Ruby の​ unlessを​定義してみます.ifと​逆の​動作を​します.

macros.sts
export syntax unless:stmt {
rule { unless ($condition:expr) { $($body:stmt)* } } => {
if (!($condition)) { $($body)* }
}
}

このように​して​定義した​マクロは​以下のように​使うことができます.

main.sts
import { unless } from "./macros.sts" for syntax;
export function withdraw(balance: number, amount: number): string {
unless (amount <= balance) {
return "insufficient funds";
}
return `withdrew ${String(amount)}`;
}

これは​単純な​ TypeScript コードに​展開され,​以下のような​コードに​なります.

展開には​以下の​コマンドを​使います.

Terminal window
npx sweetener expand src/main.sts
export function withdraw(balance: number, amount: number): string {
if (!(amount <= balance)) {
return "insufficient funds";
}
return `withdrew ${String(amount)}`;
}

型検査は​この​展開後の​コードに​対して​ tscが​行う​ため,​Sweetener が​特有の​型チェックを​行う​ことは​ありません.

また,​マクロの​ importには​ for syntaxを​つける​必要が​あり,​これは​展開後の​ TypeScript コードには​出力されません.

マクロを読む#

unlessマクロを​読み解いてみます.

export syntax unless:stmt {
rule { unless ($condition:expr) { $($body:stmt)* } } => {
if (!($condition)) { $($body)* }
}
}
  • syntax unless:stmtで,unlessと​いう​名前の​マクロを​「文(stmt) が​書ける​位置」に​定義しています.
  • rule { ... }の​中が​実際に​展開される​パターンです.()内で​1個の​式を​ $conditionと​して,{}内で​0個以上の​文を​ $bodyと​して​キャプチャしています.
    • $(...)*は​繰り返し
  • =>以降は,​実際に​ TypeScript と​して​展開される​コードを​書きます.​キャプチャした​条件式を​反転し,​その後の​ブロックに​文を​渡す​ことで​ unlessの​動作を​実装しています.

パイプラインマクロを実装してみる#

次に​ Elixir スタイル1の​パイプライン演算子を​実装する​マクロを​書いてみます.

operators.sts
export operator (|>):expr {
fixity infix;
associativity left;
precedence 35;
rule { $value:expr |> $function:ident $(. $member:ident)* ($($argument:expr),*) } => {
$function $(. $member)*($value #if(present $argument) {, $($argument), *})
}
rule { $value:expr |> $function:ident $(. $member:ident)* } => {
$function $(. $member)*($value)
}
}
main.sts
import { (|>) } from "./operators.sts" for syntax;
const result = [1, 2, 3] |> map((n) => n * 2) |> sum;

この​コードは​ TypeScript に​展開された​とき,​このようになります.

const result = sum(map([1, 2, 3], (n) => n * 2));

なる​ほど,​確かに​パイプライン演算子と​して​機能するように​マクロが​展開されていますね.

TC39 で​提案されている​ Hack スタイル(x |> f(%)のように​ %で​代入される​値の​位置を​指定する​) の​パイプライン演算子も​書く​ことができます.

他の活用例#

すでに​公式の​例と​しては

  • Effect-TS の​ボイラープレートを​マクロと​して​展開する
    • effect f(x: string) { ... }を​ const f = (x: string) => Effect.gen(function* () { ... })に​する
    • const x = yield* f()を​ x <- f()に​する
    • error NotFound { id: string }を​ Data.TaggedErrorに​する
    • service Databaseを​ Context.Tagに​する
    • handle f() { errors }を​ Effect.catchTagに​する
  • Drizzle の​テーブル定義 DSL

などが​あり,​Effect-TS, Drizzle ユーザー2と​しては​かなり​魅力的に​見えました.

Effect-TS は​特に​ボイラープレートが​多く,​読むのが​結構つらいので,​これらの​マクロで​コードレビュー時の​読みづらさが​軽減されるのではないかと​期待していたり.

現状の制約#

なんと​いっても​まだ​ alpha 版と​いう​点です.​以下の​点に​おいては​まだ​改善の​余地が​ありそうです.

エディタの支援がハイライトだけ#

LSP や​それに​付随する​エディタ支援機能の​実装自体は​ PR と​して​上がっている​ため,​それが​マージされると​かなり​開発しやすくなりそうです.

型ごとのマクロ展開を書けない#

展開は​型検査より​前に​行われるので,​型内部に​よって​マクロの​展開方​法を​変えるなどの​記述は​現時点では​行えません.

.sts, .stsx のフォーマットに Biome が対応していない#

現状 .sts, .stsxは​ Prettier でしか​フォーマットできません.​普段​私が​使っているのは​ Biome なので,​ここは​つらいな​あと​思いました.

おわりに#

ガッツリ実戦投入できる​パターンは​そこまで​多くないとは​思いますが,​ボイラープレートを​排除する​ための​手段と​して​知っておくと​良さそうだなーと​いう​感じです.

マクロ・メタプログラミングに​代表される​構文の​ hack は​どこまで​やっていいのかチームや​状況に​よって​大きく​変わると​思います.個人開発なら​これですべてを​破壊するのも​悪くないですね

邪悪な​マクロを​組まない​限り,​構文と​しては​シンプルに​なるでしょうから,​少なくとも​人間の​認知負荷に​対しては​良い​効果を​もたらしてくれると​期待しています.

AI エージェントに​対しては​トークン効率などの​面で​ベンチマークを​取る​必要が​ありそうですが.​実際どこまで​ついてきてくれるかは​気に​なる​ところです.

TypeScript で​今まで​出来なかった​ことや,​記述量が​多くなってしまっていた​部分を​解決するので,​他の​言語から​ TypeScript, Effect-TS などに​移ってきたい​場合に​使える…のか?​わからん.​誰か​実証してください.

この​記事で​上げた​もの​以外にも​便利な​マクロの​使い道を​思いついたら​ Zenn の​ Scrap, 便利な​ Sweetener マクロ n 選に​随時追記していくので,​気に​なったら​見てみてください.

Footnotes#

  1. 左辺の​評価結果を​,右辺の​関数の​第一引数に​暗黙的に​渡すスタイル. ↩

  2. こんな​ツイートを​していたりします

    ↩
GitHubで編集を提案