7 レイヤ
type / slot / effect / reducer / tile / fn / app。状態・副作用・UI が役割ごとに分かれ、暗黙のルールがない。
同じアプリを 2 つの世界で。左は Kumiki での fetch example、右はその慣用的な React 等価実装。
Kumiki — 30 行 · 278 トークン
type Quote = {text: Text, author: Text}
type Load = Idle | Loading | Loaded(Quote) | Failed(Text)
slot state : Load = Idle
effect fetchQuote cap=http.get
in=Unit
out=Result(Quote, HttpError)
policy=latest
map-request={url: "/api/quote", decode: Decoder.Json(Quote)}
reducer load on=ui.click(LoadBtn) do= state := Loading
emit fetchQuote()
reducer loaded on=fetchQuote.ok($q, _) do= state := Loaded($q)
reducer failed on=fetchQuote.err($e, _) do= state := Failed("request failed")
tile LoadBtn = button(text="Load quote", onClick=load)
tile App = column(
LoadBtn,
match state with
| Idle -> text("Click to load.")
| Loading -> spinner()
| Loaded(q) -> card(text(q.text), text("— " + q.author)) {pad: "md"}
| Failed(msg) -> text(msg) {color: "danger"})
{pad: "lg", gap: "md"}
app EffectHttp
caps = [http.get]
routes = {"/" -> App, "/404" -> App}
init = []React — 40 行 · 328 トークン
import { useRef, useState } from "react";
type Quote = { text: string; author: string };
type Load =
| { tag: "idle" } | { tag: "loading" }
| { tag: "loaded"; quote: Quote }
| { tag: "failed"; message: string };
export function App() {
const [state, setState] = useState<Load>({ tag: "idle" });
const ctrl = useRef<AbortController | null>(null);
async function load() {
ctrl.current?.abort(); // "latest" policy を手書き
const c = (ctrl.current = new AbortController());
setState({ tag: "loading" });
try {
const res = await fetch("/api/quote", { signal: c.signal });
setState({ tag: "loaded", quote: await res.json() });
} catch {
if (!c.signal.aborted)
setState({ tag: "failed", message: "request failed" });
}
}
return (
<div>
<button onClick={load}>Load quote</button>
{state.tag === "idle" && <p>Click to load.</p>}
{state.tag === "loading" && <Spinner />}
{state.tag === "loaded" && (
<blockquote>
<p>{state.quote.text}</p>
<footer>— {state.quote.author}</footer>
</blockquote>
)}
{state.tag === "failed" && <p className="error">{state.message}</p>}
</div>
);
}loading demo…
書き換えて試したくなったら Playground へ。
注目すべき点:
policy=latest はアノテーション 1 つ。 React で古いレスポンスを捨てるには AbortController の手書きの儀式が要る。まさに「テキストの外」にあって AI の編集で壊れる類の機構。caps = [http.get] はコンパイラが検査し、未宣言 capability の effect はコンパイルエラーになる。reducer failed や tile App だけを差し替えられる。他は一切動かさなくてよい。差はアプリの規模とともに開く。ベンチマークでは、Kumiki は React 等価実装よりトークン約 1.4× 減・行数約 2× 減。