Goの関数をTypeScriptからimportする【Content Mapper実用編】
前回の記事では、Content Mapper で Mermaid の図を型にしました。作ってみて楽しかったものの、実務で使うならコードで読めれば十分だな、というのが正直な感想でした。
今回はもう少し役に立ちそうなものとして、Go のコードをフロントエンドの TypeScript からそのまま import してみます。
import { Price, type Order } from "../shared/checkout/checkout.go";
const quote = Price(order); // 型は Go の宣言から、実行はブラウザの Wasm で
コード生成したファイルをコミットする必要はありません。Go を変えると、TypeScript 側がそのまま型エラーになります。
!
執筆時点の TypeScript は 7.1.0-beta です。API や設定は正式版までに変わる可能性があります。
動くものは super-himetan/go-ts-shared-logic に置いています。
解決したいこと
金額計算や入力チェックのようなロジックは、フロントエンドとバックエンドの両方に必要になります。画面ではすぐに結果を出したいですし、サーバーではクライアントの値を信用せずに計算し直す必要があるからです。
バックエンドが Go だと、次のどちらかになりがちです。
同じロジックを TypeScript でも書く。端数処理などがいつかずれます。
毎回 API を呼ぶ。入力のたびに通信が発生します。
型だけなら OpenAPI などから生成できますが、ロジックは共有できません。そこで、Go のコードを1か所に書いて、3つの経路で使います。
checkout.go ─┬ tsc / エディタ → Content Mapper → 型
├ Vite プラグイン → GOOS=js GOARCH=wasm go build → Wasm
└ server/main.go → 普通の import
Content Mapper が作るのは型だけで、実行時には使われません。これは前回と同じ構成で、ブラウザで動くものは Vite プラグインが用意します。
Go を書くだけで型が付く
題材はコーヒーの注文です。Go 側には特別な記法がなく、ただのパッケージです。
shared/checkout/checkout.go
// Size is a cup size.
type Size string
const (
SizeS Size = "S"
SizeM Size = "M"
SizeL Size = "L"
)
// Order is what a customer is about to buy.
type Order struct {
Items []LineItem json:"items"
Coupon string json:"coupon,omitempty"
}
// Price computes the amount to pay. The discount and the tax both round down.
func Price(order Order) (Quote, error) {
// ...
}
mapper はこのファイルを、次のような仮想的な TypeScript として TypeScript に見せます。
/** Size is a cup size. */
export type Size = "S" | "M" | "L";
/** Order is what a customer is about to buy. */
export interface Order {
items: LineItem[] | null;
coupon?: string;
}
/**
- Price computes the amount to pay. The discount and the tax both round down.
- @throws {Error} The Go function returned an error.
*/
export declare function Price(order: Order): Quote;
Go の宣言がそのまま型になっていて、次のように変換されます。
Go
TypeScript
json:"items"
プロパティ名 items
json:",omitempty"
省略可能なプロパティ
type Size string と定数
文字列の union
(Quote, error)
Quote を返し、error は例外になる
[]LineItem
LineItem[] | null
最後の null が地味に大事です。Go の nil スライスは encoding/json で null になるので、TypeScript の型に null を残しています。忘れて触ると、ちゃんとコンパイルエラーになります。
Validate(order).length;
// Object is possibly 'null'.
const order: Order = { items: [{ drink: "tea", size: "M", quantity: 1 }] };
// Type '"tea"' is not assignable to type 'Drink'.
Go を変えると TypeScript が落ちる
Quote.Total のタグを json:"grandTotal" に変えて型チェックすると、古い名前を使っている TypeScript がエラーになります。
web/app.ts(51,36): error TS2339: Property 'total' does not exist on type 'Quote'.
逆に、JSON で正しく渡せない Go の書き方は、Go のファイルの上でエラーになります。int64 は JavaScript の数値に収まらないことがありますし、chan は JSON にできません。
type Result struct {
Big int64 json:"big"
// ^^^^^ 'int64' can exceed what a JavaScript number holds exactly. Use int, or a string.
}
func Compute(events chan int) Result {
// ^^^^^^^^ This type has no JSON form that TypeScript can describe.
}
エディタの機能も Go まで届きます。
Price にホバーすると、Go のドキュメントコメントが表示されます。
定義ジャンプで checkout.go の func Price に移動します。
quote.subtotal の定義ジャンプは、Go の json:"subtotal" の中の subtotal に移動します。
quote.subtotal をリネームすると、Go のタグも一緒に変わります。
Go の関数名のリネームだけは、あえて止めています。TypeScript からは Go 側の呼び出し元を直せないためです。こういう制御を位置情報ごとに指定できるのが、Content Mapper の面白いところだと思います。
ブラウザでは Go がそのまま動く
実行時は Vite プラグインが .go を受け取り、パッケージを Wasm にビルドします。Price の中身は Go のままで、JavaScript に変換しているわけではありません。
この画面の金額は、数量やクーポンを変えるたびに通信なしで計算されています。割引も税も切り捨てで、同じ Go のコードなので端数の処理がずれる余地がありません。
サーバーは注文を受けたら同じパッケージで計算し直し、画面に出ていた金額と違えば拒否します。
server/main.go
quote, err := checkout.Price(req.Order)
// ...
if quote.Total != req.ExpectedTotal {
writeJSON(w, http.StatusConflict, checkout.ErrorResponse{
Message: fmt.Sprintf("金額が変わりました: 表示 %s / 実際 %s",
checkout.FormatYen(req.ExpectedTotal), checkout.FormatYen(quote.Total)),
})
return
}
ソースは1つですが、ビルドはネイティブと Wasm で別です。そこで同じテストケースの JSON を Go のテストと Wasm のテストの両方で読み、同じ結果になることを確かめています。
実装
全体でも mapper が73行、Vite プラグインが106行です。Go ファイルの解析だけは、Go の標準パッケージ go/parser を使う小さなツールにしています(テストを除いて約600行)。
Content Mapper 本体
mapper は TypeScript から呼ばれる外部プログラムです。実装言語は自由なので、Go の解析は Go に任せて、結果を ts-content-mapper で TypeScript に組み立てています。
packages/go-mapper/server.mjs(抜粋)
runContentMapper({
diagnosticSource: "go",
transform(params, context) {
const b = new MappedCodeBuilder(params.content, { positionEncoding: context.positionEncoding });
const schema = extractSchema(params.content); // tools/goschema の結果
writeTypes(schema, b);
return { ...b.build(".ts"), diagnostics: schema.diagnostics };
},
});
appendVerbatim は Go のソースの範囲をそのまま写す命令で、元の位置との対応が残ります。関数名ではリネームだけ外しています。
const NO_RENAME = SpanMapFeature.All & ~SpanMapFeature.Rename;
b.append("export declare function ");
b.appendVerbatim(fn.span.start, fn.span.end, NO_RENAME);
b.append((${params}): ${fn.result};\n);
位置は UTF-16 に変換してから渡しています。Go のオフセットはバイト単位なので、変換しないと日本語のコメントがあるだけでエラーの位置がずれます。
tsconfig.json(抜粋)
{
"contentMappers": [{ "package": "go-mapper", "extensions": [".go"] }]
}
packages/go-mapper/package.json(抜粋)
{
"typescript": {
"contentMapper": {
"exec": ["node", "server.mjs"],
"compilerOptions": [],
"dynamicConfig": false
}
}
}
JavaScript と Go の橋渡し
Vite プラグインは、パッケージの公開関数を並べた main.go を生成して Wasm にビルドします。
func main() {
bridge.Serve("__go_mapper_checkout_...", map[string]any{
"Menu": pkg.Menu,
"Validate": pkg.Validate,
"Price": pkg.Price,
// ...
})
}
受け取った関数は reflect で呼びます。引数と戻り値はどちらも JSON です。これで型ごとのラッパーを生成せずに済みます。
bridge/bridge.go(抜粋)
func Invoke(fn any, args []string) (result string, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
v := reflect.ValueOf(fn)
t := v.Type()
if len(args) != t.NumIn() {
return "", fmt.Errorf("expected %d argument(s), got %d", t.NumIn(), len(args))
}
in := make([]reflect.Value, len(args))
for i, arg := range args {
p := reflect.New(t.In(i))
if err := json.Unmarshal([]byte(arg), p.Interface()); err != nil {
return "", fmt.Errorf("argument %d: %w", i, err)
}
in[i] = p.Elem()
}
// 呼び出して、error を分けて、残りを JSON にして返す
}
recover しているのは、panic が Wasm のランタイムまで届くと、以降の呼び出しがすべて失敗するためです。
checkout.go の import は、Vite プラグインによって次のようなモジュールになります。
import { lookup } from "\0go-mapper:package:...";
export const SizeS = "S";
export const Price = lookup("Price");
Wasm の読み込みはパッケージごとに1回だけです。同じパッケージのファイルは、ネイティブと同じように1つの Go ランタイムと状態を共有します。
実用編と言いつつの注意点
良いことばかりではないので、試して分かった制限も書いておきます。
Wasm が大きい: 通常の Go で作るとランタイムごと入るので、このデモで約 4.5MB あります。読み込むまで動かないので、読み込み中の表示も必要です。
解析はファイル単位: Content Mapper は1ファイルずつ変換するので、TypeScript に公開する型は同じファイルに置く必要があります。別のファイルに書かれた MarshalJSON も検出できません。
数値は number にまとまる: int の引数に 1.5 を渡してもコンパイルは通り、実行時に例外になります。
union は閉じた型ではない: Go では Size("XL") も "" も作れますが、型は定数の一覧です。バックエンドから来た値を信用するための型ではありません。
mapper のエラーが先に止まる: tsc は mapper のエラーがあると、その先の型エラーを報告しません。
それでも、小さなルールの塊を共有するだけなら、二重に実装するより安全で、毎回 API を呼ぶより速いと感じました。
まとめ
Content Mapper の面白いところは、変換処理が TypeScript の外にあることです。今回は Go のパーサーをそのまま使えたので、型の変換を自分で頑張る必要があまりありませんでした。
Go との変換には、他にもいろいろな遊び方がありそうです。特に試してみたいのが、gRPC の .proto や OpenAPI の定義のような DSL です。こうしたものは今でもコード生成で型を作っていることが多いと思いますが、その変換処理を Content Mapper に任せると、次のように書ける可能性があります。
// どちらもまだ作っていない想像の例です
import type { GetUserRequest } from "./user.proto";
import type { paths } from "./openapi.yaml";
生成物の置き場所や再生成の手順を考えなくてよくなり、定義の間違いは .proto や .yaml の上で赤線になり、定義ジャンプで DSL まで戻れます。既存のパーサーや生成ツールをそのまま mapper から呼べるのも相性が良さそうです。
コード生成を置き換えるとまでは言えませんが、スキーマと実装の間の継ぎ目をもっと減らし、よりシームレスな実装ができるようになると考えられます。
大AI時代だからこそ、こんな想像力が刺激されるようなAPIが生えると色々手早く試せてワクワクしますね!
人間がレビューしやすい型安全な状態フローの生成
!
釣りタイトルです!Content Mapperを使って全く役に立たないものを作ってしまっています!
役立つContent Mapperの使い方は TSKaigi Sendai 2026のuhyoさんの「Content Mapper APIの応用例〜CSS Modules、言語ファイル〜 が分かりやすいです。
Mermaid の状態遷移図を、TypeScript の型として扱えるようにしてみました。Mermaidの図から矢印を1本消すと、その矢印を使っていたコードは型エラーになります。
使っているのは、TypeScript 7.1 Beta で導入された Content Mapper です。.ts ではないファイルを仮想的な TypeScript に変換する仕組みで、エラーやエディタの機能が元のファイルの上で動きます。
!
執筆時点の TypeScript は 7.1.0-beta です。API や設定は正式版までに変わる可能性があります。
Mermaid図を有向グラフと見立てて型に変換する
コーヒーマシンの状態遷移図を coffee.mmd として用意します。
ソースには、図に出てこない次の1行も書いています。%% は Mermaid のコメントなので、描画には影響しません。
%% field drink: "コーヒー" | "カフェラテ" | "抹茶ラテ"
この状態遷移図を、状態を頂点、イベントを辺とする有向グラフと見立てています。そこで、これを次の型に変換します。
export interface Graph {
Menu: {
chooseDrink: "PlacingCup";
};
PlacingCup: {
backToMenu: "Menu";
pressStart: "Brewing";
};
Brewing: {
brewed: "Served";
};
Served: {
};
}
export interface Fields {
drink: "コーヒー" | "カフェラテ" | "抹茶ラテ";
}
export interface Payloads {
chooseDrink: { drink: Fields["drink"] };
}
Content Mapperを使うことでimport type { Graph } from "./coffee.mmd" とMermaidをimportするように書けば、この型を使うことができます。
そうすることで3つのことができるようになります。
図にない遷移を書けなくする
今いる状態から出ている矢印だけをメソッドにすると、呼び出しの順番が図の通りに制限されます。
menu.chooseDrink({ drink: "コーヒー" }).pressStart().brewed(); // OK
menu.pressStart(); // エラー: Menu から pressStart の矢印はない
図を変えれば、コードの正しさも変わります。PlacingCup --> Menu: backToMenu を消すと、backToMenu を呼んでいたコードがエラーになります。
Property 'backToMenu' does not exist on type 'Flow<"PlacingCup">'.
たとえばReactを使う場合、今の状態から送れるイベントだけをイベントハンドラーとして補完に出るようにもできます。
引数も図の型で制御する
矢印に書いた chooseDrink(drink) の drink は、イベントの引数です。型は %% field drink に書いたものがそのまま使われます。
menu.chooseDrink({ drink: "コーヒー" }); // OK
menu.chooseDrink({ drink: "抹茶" }); // エラー: 図にない飲み物
menu.chooseDrink(); // エラー: 引数が必要
menu.chooseDrink({ drink: "コーヒー" }).pressStart({ drink: "コーヒー" }); // エラー: pressStart に引数はない
Type '"抹茶"' is not assignable to type '"カフェラテ" | "コーヒー" | "抹茶ラテ"'.
メニューを変えるときも、直すのは図だけです。図に "抹茶" を足せば上のコードは通るようになり、"抹茶ラテ" を消せば、それを渡していたコードがエラーになります。
Mermaid図の中に赤線を出す
mapper は、図の間違いを位置付きで報告できます。エディタでは .mmd の上に赤線が出ます。
この GIF では、状態が持つデータの名前を drnk と間違えています。Mermaid にとってはただの説明文なので、プレビューは正常に描画されたままです。図としては正しくても、型の世界では間違いとして扱えます。
リネームも図まで届きます。コードの backToMenu をリネームすると、.mmd の中の名前まで書き換わります。
実装
型の定義と mapper の全体です。mapper は58行で、図を読んで型の文字列を組み立てているだけです。興味があれば覗いてみてください。
図にない遷移を書けなくする型
src/flow.ts
import type { Graph, Payloads } from "./coffee.mmd";
// 今いる状態から出ている矢印だけが、メソッドになる
// 図に引数が書いてあるイベントは、その引数を必須にする
export type Flow<S extends keyof Graph> = {
[Event in keyof Graph[S]]: (
...args: Event extends keyof Payloads ? [Payloads[Event]] : []
) => Flow<Graph[S][Event] & keyof Graph>;
};
declare const menu: Flow<"Menu">;
menu.chooseDrink({ drink: "コーヒー" }).pressStart().brewed();
menu.chooseDrink({ drink: "コーヒー" }).backToMenu();
mapper 本体と設定
ts-content-mapper という SDK を使っています。appendVerbatim は .mmd の範囲をそのまま写す命令で、元の位置との対応が残るので、リネームが図まで届きます。
mmd-mapper/server.mjs
import { MappedCodeBuilder, runContentMapper } from "ts-content-mapper";
runContentMapper({
diagnosticSource: "mermaid",
transform({ content }, { positionEncoding }) {
// 矢印とフィールドを読み取る
const edges = [...content.matchAll(/^\s*(\w+) --> (\w+): (\w+)(?:((\w+)))?$/dgm)];
const fields = [...content.matchAll(/^\s*%% field (\w+): (.+)$/dgm)];
const hasField = (name) => fields.some(([, field]) => field === name);
const states = new Set(edges.flatMap(([, from, to]) => [from, to]));
const b = new MappedCodeBuilder(content, { positionEncoding });
// 状態ごとの矢印
b.append("export interface Graph {\n");
for (const state of states) {
b.append( ${state}: {\n);
for (const edge of edges.filter(([, from]) => from === state)) {
const [start, end] = edge.indices[3]; // イベント名の位置
b.append(" ");
b.appendVerbatim(start, end);
b.append(: "${edge[2]}";\n);
}
b.append(" };\n");
}
b.append("}\n");
b.append("export declare const graph: Graph;\n");
// フィールドの型は図の文字列をそのまま写す
b.append("export interface Fields {\n");
for (const field of fields) {
const [start, end] = field.indices[2];
b.append( ${field[1]}: );
b.appendVerbatim(start, end);
b.append(";\n");
}
b.append("}\n");
// イベントの引数
b.append("export interface Payloads {\n");
for (const [, , , event, field] of edges) {
if (hasField(field)) b.append( ${event}: { ${field}: Fields["${field}"] };\n);
}
b.append("}\n");
// 図の間違いは、位置を付けて報告できる
const diagnostics = edges
.filter((edge) => edge[4] && !hasField(edge[4]))
.map((edge) => ({
code: 1,
messageText: '${edge[4]}' has no '%% field ${edge[4]}: ...' line.,
start: edge.indices[4][0],
length: edge[4].length,
}));
return { ...b.build(".ts"), diagnostics };
},
});
mmd-mapper/package.json
{
"name": "mmd-mapper",
"type": "module",
"dependencies": { "ts-content-mapper": "0.1.1" },
"typescript": {
"contentMapper": {
"exec": ["node", "server.mjs"],
"compilerOptions": [],
"dynamicConfig": false
}
}
}
tsconfig.json
{
"compilerOptions": { "module": "esnext", "moduleResolution": "bundler", "strict": true, "noEmit": true, "types": [] },
"contentMappers": [{ "package": "mmd-mapper", "extensions": [".mmd"] }],
"include": ["src"]
}
mapper は外部のプログラムとして動くので、tsc では実行の許可が必要です。
tsc --runExternalCode
実行時に動かす Vite プラグイン
Content Mapper の変換結果は tsc とエディタのためのもので、実行時には使われません。そのため、実行時の .mmd は Vite プラグインで JavaScript に変換します。
tsc / エディタ → Content Mapper → 型
Vite(実行時) → Vite プラグイン → JavaScript
プラグインは、図から矢印の表を作って graph として export します。mapper が export declare const graph: Graph と宣言しているので、この値には Graph の型が付きます。
mmd-mapper/vite.mjs
// .mmd を import したら、矢印の表を JavaScript として返す
export function mmd() {
return {
name: "mmd",
enforce: "pre",
transform(code, id) {
if (!id.split("?")[0].endsWith(".mmd")) return;
const graph = {};
for (const [, from, to, event] of code.matchAll(/^\s*(\w+) --> (\w+): (\w+)(?:(\w+))?$/gm)) {
graph[from] ??= {};
graph[to] ??= {};
graph[from][event] = to;
}
return { code: export const graph = ${JSON.stringify(graph)};, map: null };
},
};
}
vite.config.mjs
import { mmd } from "./mmd-mapper/vite.mjs";
export default { plugins: [mmd()] };
graph を使えば、Flow 型の通りに動く実体を作れます。今の状態から出ている矢印を、次の状態へ進むメソッドにしています。
src/machine.ts
import { graph, type Graph } from "./coffee.mmd";
import type { Flow } from "./flow";
export const flowAt = <S extends keyof Graph>(state: S): Flow<S> =>
Object.fromEntries(
Object.entries(graph[state]).map(([event, to]) => [event, () => flowAt(to as keyof Graph)]),
) as Flow<S>;
src/main.ts
import { flowAt } from "./machine";
const cup = flowAt("Menu").chooseDrink({ drink: "コーヒー" });
console.log(Object.keys(cup)); // ["backToMenu", "pressStart"]
動くデモは super-himetan/mermaid-machine に置いています。
まとめ
図を有向グラフとして型にすると、遷移の順番だけでなく、イベントの引数まで図に書いた型で検査できました。
正直実務で生かそうとするとデータが増えてグラフは複雑になりそうなので、私はコードで読めれば十分だなと思いました。
気が向いたらちゃんと使いものになるContent Mapperの応用も書こうと思います。
TSのcontent mapperを使ってGeoParquetの型推論を便利に賢くしてみる
先日、TypeScript 7.1のベータ版が公開され、content mapperという機能が入りました。
devblogs.microsoft.com
ざっくり言うと、TypeScriptが知らない拡張子のファイルをimportしたときに、外部のプログラムに「これをTypeScriptのコードにしてくれ」と頼める機能です。.vue や .svelte のようなファイルを想定したものだと思うのですが、GeoParquetをimportしたら型が付くようにもできるのでは?と思ったのが始まりでした。
以前DuckDB WASMの記事を書いたときも、戻り値がanyで「Typed DuckDBが欲しい...」とぼやいていた記憶があります。それに若干関連するような内容です。
zenn.dev
(TypeScript 7.1はまだベータ版なので、仕様は今後変わるかもしれません。)
サンプルデータ
まずは今回使うデータを見てみます。札幌市の施設データ(架空です)で、10区×100件、計1,000件あります。区ごとにrow group(行のまとまり)を分けて書き出しています。
列はこんな感じです。
列
型
中身
ward
文字列
区名
category
文字列
駅、公園、学校、病院、図書館、コンビニ
name
文字列
施設名
opened
整数
開業年(不明ならnull)
visitors
整数
年間利用者数
geometry
Point
位置(EPSG:6680)
ほかにもid、営業中かどうか、更新日時、タグなどが入っています。ogr2ogrで書き出したら、全部の列がnull許容になっていました。
Parquetはrow group×列ごとに、最小値・最大値・nullの数といった統計情報をファイルに書いています。今回のファイルだとこうなっていました。
row group
行数
wardの最小 / 最大
openedのnullの数
0
100
中央区 / 中央区
0
1
100
北区 / 北区
0
2
100
東区 / 東区
0
3
100
白石区 / 白石区
0
4
100
豊平区 / 豊平区
0
5
100
南区 / 南区
25
6
100
西区 / 西区
0
7
100
厚別区 / 厚別区
0
8
100
手稲区 / 手稲区
37
9
100
清田区 / 清田区
0
区ごとに分けているので、wardの最小と最大が同じ値になっています。また、openedのnullは南区と手稲区にしかありません。
これらの情報は、全部ファイル末尾のフッタと呼ばれる部分にまとまっています。
PAR1 | row group 0 | row group 1 | ... | row group 9 | フッタ | フッタの長さ | PAR1
今回のファイルだと、106KBのうちフッタは24KBほどです。本来はクエリを速くするための情報(条件に合わないrow groupを読み飛ばす)なのですが、これを型にも使ってしまおうというのが今回の話です。
やったこと
このファイルを読み込むコードを書いてみます。
example/src/sapporo-facilities.ts
import type { Row, RowCounts, RowGroups } from "../data/facilities.parquet";
const read = readParquet<Row, RowGroups, RowCounts>();
// 全部読む
const all = await read(file);
all[0].ward; // "中央区" | "北区" | "東区" | ... | "清田区"
all.length; // 1000
// 南区だけ
const minami = await read(file, { filter: { ward: "南区" } });
minami[0].ward; // "南区"
// 開業年が不明な施設
const unknown = await read(file, { filter: { opened: null } });
unknown[0].ward; // "南区" | "手稲区"
// 中央区の名前と開業年だけ
const chuo = await read(file, { filter: { ward: "中央区" }, columns: ["name", "opened"] });
chuo[0].opened; // number
型は1行も手で書いていません。.parquet をimportしただけです。
注目してほしいのは3つ目です。さっきの統計の表で、openedにnullがあるのは南区と手稲区だけでした。それが、データを読む前に型でわかっています。
リポジトリはこちらです。
groovyjovy/content-mapper-fgb-gpq
スキーマから型をつくる
まずはスキーマだけを使ってみます。facilities.parquet をimportすると、TypeScriptからはこう見えます。
export interface Row {
id: bigint | null;
ward: string | null;
opened: number | null;
updated_at: Date | null;
tags: (string | null)[] | undefined;
geometry: Point<"EPSG:6680"> | null;
// ...
}
列名を打ち間違えたり、数値の列を文字列と比べたりするとちゃんと怒られます。ただ、スキーマ上は全部null許容なので、どの列を触るにもnullチェックが必要です。これだと少し不便ですね。
(INT64がbigintになっているのは、読み込みに使うhyparquetがそう返すからです。型は「hyparquetで読んだときの値の形」に合わせています。)
統計から型を絞り込む
ここからが本題です。統計から読み取っているのは以下の3つだけです。
最小と最大が同じなら、そのrow groupは全行が同じ値なので、リテラル型にできる
nullの数が0なら、そのrow groupにnullはないので、| null を外せる
行数はそのまま length の型にできる
row group 0(中央区)なら { ward: "中央区"; opened: number; ... }、row group 5(南区)なら { ward: "南区"; opened: number | null; ... } という具合に、row groupごとに型をつくります。何も絞らずに読んだときの型は、この10個のunionです。
なので、wardで分岐するとopenedの型も変わります。
for (const f of all) {
if (f.ward === "中央区") f.opened; // number
if (f.ward === "南区") f.opened; // number | null
}
条件で絞ったときは、その条件を満たしえないrow groupを型から消しています。
filter: { ward: "南区" } だと、wardが南区になりうるのはrow group 5だけなので、残りは消える
filter: { opened: null } だと、nullの数が0のrow groupはありえないので消えて、南区と手稲区だけが残る
filter: { ward: "新宿区" } だと、どれも残らずに never[] になる。読む前から0件とわかります
row groupを直接指定することもできます。
const rg = await read(file, { rowGroups: [5, 8] });
rg[0].ward; // "南区" | "手稲区"
rg.length; // 200
await read(file, { rowGroups: [10] }); // row groupは0〜9しかないのでエラー
200は100+100を型の上で足しています。TypeScriptの型には足し算がないので、数値を文字列にして1桁ずつ筆算しています。ちょっとやりすぎた気もします。
ちなみに、実行時もhyparquetが同じ統計を見てrow groupを読み飛ばしています。型と実行時で同じ情報を見ているので、話がズレないのがいいところです。
GeoParquetならでは
GeoParquetは、Parquetのフッタに geo というJSONを足したものです。
{
"primary_column": "geometry",
"columns": {
"geometry": {
"encoding": "WKB",
"geometry_types": ["Point"],
"crs": { "id": { "authority": "EPSG", "code": 6680 } }
}
}
}
ここからジオメトリの種別とCRSを拾って、geometry列を Point<"EPSG:6680"> のような型にしています。CRSが型に載るので、平面直角座標の点を経緯度の点として扱おうとすると怒られます。
const lonLat: Point<"OGC:CRS84"> | null = facility.geometry;
// Type '"EPSG:6680"' is not assignable to type '"OGC:CRS84"'.
ほかにも、GeoParquetのcovering(各行のbboxを持つ列)を使って、bbox オプションで範囲内のrow groupだけ読めるようにしています。
仕組み
content mapper
使う側は、tsconfigにどの拡張子をどのmapperに任せるかを書きます。
example/tsconfig.json
{
"contentMappers": [
{ "package": "geoparquet-content-mapper", "extensions": [".parquet", ".parquet.ref"] }
]
}
mapper側は、package.jsonに起動方法を書いておきます。
packages/geoparquet-content-mapper/package.json
{
"typescript": { "contentMapper": { "exec": ["node", "./src/mapper.ts"] } }
}
あとはTypeScript(tsgo)がmapperを子プロセスとして起動し、.parquet のimportを見つけるたびにJSON-RPCで変換を頼んできます。mapperはTypeScriptのコードを文字列で返すだけです。通信まわりは以下のSDKがやってくれるので、transform を1つ書けば済みます。
sxzz/ts-content-mapper
型チェックのときにmapper(つまり任意のコード)が実行されるので、tsc --runExternalCode のようにフラグが必要です。信頼できるリポジトリでだけ有効にしましょう。
フッタだけ読む
Parquetのファイル末尾8バイトは、フッタの長さと PAR1 という文字列です。
$ tail -c 8 facilities.parquet | xxd
00000000: ab5f 0000 5041 5231 ._..PAR1
ab5f0000 はリトルエンディアンで24,491です。なので、末尾8バイトを読んでフッタの長さを知り、そのぶんだけ読めばフッタが手に入ります。解析はhyparquetに任せています。
packages/geoparquet-content-mapper/src/mapper.ts(抜粋)
const tail = await readTail(8);
const metadataLength = new DataView(tail.buffer, tail.byteOffset).getUint32(0, true);
const footer = await readTail(metadataLength + 8);
ちなみに、mapperにはファイルの中身も文字列として渡されてくるのですが、UTF-8として読まれているのでバイナリは壊れています。なので、ファイル名から自分で開き直しています。
mapperが返すコード
mapperが返しているのはこんなコードです。
export interface Row { ward: string | null; opened: number | null; /* ... */ }
type RowGroup0 = { ward: { nulls: "none"; value: "中央区" }; opened: { nulls: "none" }; /* ... */ };
type RowGroup5 = { ward: { nulls: "none"; value: "南区" }; /* ... */ };
export type RowGroups = [RowGroup0, RowGroup1, /* ... */ RowGroup9];
export type RowCounts = [100, 100, 100, 100, 100, 100, 100, 100, 100, 100];
mapperは統計を書き出しているだけで、絞り込みは読み込み関数(hyparquetの薄いラッパー)の型の方でやっています。中身は条件型とmapped typeをひたすら組み合わせているだけなので、興味があればリポジトリを覗いてみてください。
大きいファイルでも
フッタだけ読めばいいので、ファイルがどれだけ大きくても型を作るのに必要なのはフッタだけです。
リモートのファイルの場合は、URLを書いた .parquet.ref というファイルを置いておきます。
example/data/places.parquet.ref
{ "url": "overturemaps-us-west-2.s3.amazonaws.com" }
これをimportすると、mapperがHTTPのRangeリクエストでフッタだけ取ってきます。Overture Mapsのplaces(日本を含むファイル、約733MB)だと、取得したのは1.6MBほどでした。256個のrow groupの行数も型の上で足して、length は 5528489 になっています。
実行時も同じで、札幌市中心部のbboxで confidence >= 0.8 のPOIを読むと7,947件、取得したのはフッタとデータを合わせて5MBもありませんでした。
気をつけること
便利そうに見えますが、いくつか気をつけることがあります。
型は「型チェックしたときのファイル」のスナップショットです。データが更新されると型が嘘になります。特に統計で絞り込んだ型や行数は、そのファイルにしか成り立ちません。リモートのURLはバージョンやコミットで固定しておくのが良さそうです
ユーザーがアップロードしたファイルのように、実行時に決まるファイルには使えません
統計が書かれていないファイルだと絞り込めません(スキーマの型だけになります)
エディタはmapperのプロセスを起動したまま使い続けるので、mapperを修正したら言語サーバーの再起動が必要です。これで結構ハマりました
おわりに
Parquetに関してはそれっぽいものができました。フッタに書いてある情報だけでここまでできるのは面白いですね。
同じ考え方はGeoZarrなど、メタデータを持つほかの形式にも使えそうなので、そのうち試してみたいです。
参考になれば嬉しいです。
Didn't even wait a day 😆
tsdown & rolldown-plugin-dts now support the TypeScript 7.1 API + content mapper. Native-speed dts for .vue files is finally real ⚡
Try the Vue demo:
sxzz/tsgo-vue
113 likes · 14 reposts · 3,001 views
Didn't even wait a day 😆
tsdown & rolldown-plugin-dts now support the TypeScript 7.1 API + content mapper. Native-speed dts for .vue files is finally real ⚡
Try the Vue demo:
sxzz/tsgo-vue
12 likes · 2 reposts
Mentions
Goの関数をTypeScriptからimportする【Content Mapper実用編】 前回の記事では、Content Mapper で Mermaid の図を型にしました。作ってみて楽しかったものの、実務で使うならコードで読めれば十分だな、というのが正直な感想でした。 今回はもう少し役に立ちそうなものとして、Go のコードをフロントエンドの TypeScript からそのまま import してみます。 import { Price, type Order } from "../shared/checkout/checkout.go"; const quote = Price(order); // 型は Go の宣言から、実行はブラウザの Wasm で コード生成したファイルをコミットする必要はありません。Go を変えると、TypeScript 側がそのまま型エラーになります。 ! 執筆時点の TypeScript は 7.1.0-beta です。API や設定は正式版までに変わる可能性があります。 動くものは super-himetan/go-ts-shared-logic に置いています。 解決したいこと 金額計算や入力チェックのようなロジックは、フロントエンドとバックエンドの両方に必要になります。画面ではすぐに結果を出したいですし、サーバーではクライアントの値を信用せずに計算し直す必要があるからです。 バックエンドが Go だと、次のどちらかになりがちです。 同じロジックを TypeScript でも書く。端数処理などがいつかずれます。 毎回 API を呼ぶ。入力のたびに通信が発生します。 型だけなら OpenAPI などから生成できますが、ロジックは共有できません。そこで、Go のコードを1か所に書いて、3つの経路で使います。 checkout.go ─┬ tsc / エディタ → Content Mapper → 型 ├ Vite プラグイン → GOOS=js GOARCH=wasm go build → Wasm └ server/main.go → 普通の import Content Mapper が作るのは型だけで、実行時には使われません。これは前回と同じ構成で、ブラウザで動くものは Vite プラグインが用意します。 Go を書くだけで型が付く 題材はコーヒーの注文です。Go 側には特別な記法がなく、ただのパッケージです。 shared/checkout/checkout.go // Size is a cup size. type Size string const ( SizeS Size = "S" SizeM Size = "M" SizeL Size = "L" ) // Order is what a customer is about to buy. type Order struct { Items []LineItem json:"items" Coupon string json:"coupon,omitempty" } // Price computes the amount to pay. The discount and the tax both round down. func Price(order Order) (Quote, error) { // ... } mapper はこのファイルを、次のような仮想的な TypeScript として TypeScript に見せます。 /** Size is a cup size. */ export type Size = "S" | "M" | "L"; /** Order is what a customer is about to buy. */ export interface Order { items: LineItem[] | null; coupon?: string; } /** - Price computes the amount to pay. The discount and the tax both round down. - @throws {Error} The Go function returned an error. */ export declare function Price(order: Order): Quote; Go の宣言がそのまま型になっていて、次のように変換されます。 Go TypeScript json:"items" プロパティ名 items json:",omitempty" 省略可能なプロパティ type Size string と定数 文字列の union (Quote, error) Quote を返し、error は例外になる []LineItem LineItem[] | null 最後の null が地味に大事です。Go の nil スライスは encoding/json で null になるので、TypeScript の型に null を残しています。忘れて触ると、ちゃんとコンパイルエラーになります。 Validate(order).length; // Object is possibly 'null'. const order: Order = { items: [{ drink: "tea", size: "M", quantity: 1 }] }; // Type '"tea"' is not assignable to type 'Drink'. Go を変えると TypeScript が落ちる Quote.Total のタグを json:"grandTotal" に変えて型チェックすると、古い名前を使っている TypeScript がエラーになります。 web/app.ts(51,36): error TS2339: Property 'total' does not exist on type 'Quote'. 逆に、JSON で正しく渡せない Go の書き方は、Go のファイルの上でエラーになります。int64 は JavaScript の数値に収まらないことがありますし、chan は JSON にできません。 type Result struct { Big int64 json:"big" // ^^^^^ 'int64' can exceed what a JavaScript number holds exactly. Use int, or a string. } func Compute(events chan int) Result { // ^^^^^^^^ This type has no JSON form that TypeScript can describe. } エディタの機能も Go まで届きます。 Price にホバーすると、Go のドキュメントコメントが表示されます。 定義ジャンプで checkout.go の func Price に移動します。 quote.subtotal の定義ジャンプは、Go の json:"subtotal" の中の subtotal に移動します。 quote.subtotal をリネームすると、Go のタグも一緒に変わります。 Go の関数名のリネームだけは、あえて止めています。TypeScript からは Go 側の呼び出し元を直せないためです。こういう制御を位置情報ごとに指定できるのが、Content Mapper の面白いところだと思います。 ブラウザでは Go がそのまま動く 実行時は Vite プラグインが .go を受け取り、パッケージを Wasm にビルドします。Price の中身は Go のままで、JavaScript に変換しているわけではありません。 この画面の金額は、数量やクーポンを変えるたびに通信なしで計算されています。割引も税も切り捨てで、同じ Go のコードなので端数の処理がずれる余地がありません。 サーバーは注文を受けたら同じパッケージで計算し直し、画面に出ていた金額と違えば拒否します。 server/main.go quote, err := checkout.Price(req.Order) // ... if quote.Total != req.ExpectedTotal { writeJSON(w, http.StatusConflict, checkout.ErrorResponse{ Message: fmt.Sprintf("金額が変わりました: 表示 %s / 実際 %s", checkout.FormatYen(req.ExpectedTotal), checkout.FormatYen(quote.Total)), }) return } ソースは1つですが、ビルドはネイティブと Wasm で別です。そこで同じテストケースの JSON を Go のテストと Wasm のテストの両方で読み、同じ結果になることを確かめています。 実装 全体でも mapper が73行、Vite プラグインが106行です。Go ファイルの解析だけは、Go の標準パッケージ go/parser を使う小さなツールにしています(テストを除いて約600行)。 Content Mapper 本体 mapper は TypeScript から呼ばれる外部プログラムです。実装言語は自由なので、Go の解析は Go に任せて、結果を ts-content-mapper で TypeScript に組み立てています。 packages/go-mapper/server.mjs(抜粋) runContentMapper({ diagnosticSource: "go", transform(params, context) { const b = new MappedCodeBuilder(params.content, { positionEncoding: context.positionEncoding }); const schema = extractSchema(params.content); // tools/goschema の結果 writeTypes(schema, b); return { ...b.build(".ts"), diagnostics: schema.diagnostics }; }, }); appendVerbatim は Go のソースの範囲をそのまま写す命令で、元の位置との対応が残ります。関数名ではリネームだけ外しています。 const NO_RENAME = SpanMapFeature.All & ~SpanMapFeature.Rename; b.append("export declare function "); b.appendVerbatim(fn.span.start, fn.span.end, NO_RENAME); b.append((${params}): ${fn.result};\n); 位置は UTF-16 に変換してから渡しています。Go のオフセットはバイト単位なので、変換しないと日本語のコメントがあるだけでエラーの位置がずれます。 tsconfig.json(抜粋) { "contentMappers": [{ "package": "go-mapper", "extensions": [".go"] }] } packages/go-mapper/package.json(抜粋) { "typescript": { "contentMapper": { "exec": ["node", "server.mjs"], "compilerOptions": [], "dynamicConfig": false } } } JavaScript と Go の橋渡し Vite プラグインは、パッケージの公開関数を並べた main.go を生成して Wasm にビルドします。 func main() { bridge.Serve("__go_mapper_checkout_...", map[string]any{ "Menu": pkg.Menu, "Validate": pkg.Validate, "Price": pkg.Price, // ... }) } 受け取った関数は reflect で呼びます。引数と戻り値はどちらも JSON です。これで型ごとのラッパーを生成せずに済みます。 bridge/bridge.go(抜粋) func Invoke(fn any, args []string) (result string, err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic: %v", r) } }() v := reflect.ValueOf(fn) t := v.Type() if len(args) != t.NumIn() { return "", fmt.Errorf("expected %d argument(s), got %d", t.NumIn(), len(args)) } in := make([]reflect.Value, len(args)) for i, arg := range args { p := reflect.New(t.In(i)) if err := json.Unmarshal([]byte(arg), p.Interface()); err != nil { return "", fmt.Errorf("argument %d: %w", i, err) } in[i] = p.Elem() } // 呼び出して、error を分けて、残りを JSON にして返す } recover しているのは、panic が Wasm のランタイムまで届くと、以降の呼び出しがすべて失敗するためです。 checkout.go の import は、Vite プラグインによって次のようなモジュールになります。 import { lookup } from "\0go-mapper:package:..."; export const SizeS = "S"; export const Price = lookup("Price"); Wasm の読み込みはパッケージごとに1回だけです。同じパッケージのファイルは、ネイティブと同じように1つの Go ランタイムと状態を共有します。 実用編と言いつつの注意点 良いことばかりではないので、試して分かった制限も書いておきます。 Wasm が大きい: 通常の Go で作るとランタイムごと入るので、このデモで約 4.5MB あります。読み込むまで動かないので、読み込み中の表示も必要です。 解析はファイル単位: Content Mapper は1ファイルずつ変換するので、TypeScript に公開する型は同じファイルに置く必要があります。別のファイルに書かれた MarshalJSON も検出できません。 数値は number にまとまる: int の引数に 1.5 を渡してもコンパイルは通り、実行時に例外になります。 union は閉じた型ではない: Go では Size("XL") も "" も作れますが、型は定数の一覧です。バックエンドから来た値を信用するための型ではありません。 mapper のエラーが先に止まる: tsc は mapper のエラーがあると、その先の型エラーを報告しません。 それでも、小さなルールの塊を共有するだけなら、二重に実装するより安全で、毎回 API を呼ぶより速いと感じました。 まとめ Content Mapper の面白いところは、変換処理が TypeScript の外にあることです。今回は Go のパーサーをそのまま使えたので、型の変換を自分で頑張る必要があまりありませんでした。 Go との変換には、他にもいろいろな遊び方がありそうです。特に試してみたいのが、gRPC の .proto や OpenAPI の定義のような DSL です。こうしたものは今でもコード生成で型を作っていることが多いと思いますが、その変換処理を Content Mapper に任せると、次のように書ける可能性があります。 // どちらもまだ作っていない想像の例です import type { GetUserRequest } from "./user.proto"; import type { paths } from "./openapi.yaml"; 生成物の置き場所や再生成の手順を考えなくてよくなり、定義の間違いは .proto や .yaml の上で赤線になり、定義ジャンプで DSL まで戻れます。既存のパーサーや生成ツールをそのまま mapper から呼べるのも相性が良さそうです。 コード生成を置き換えるとまでは言えませんが、スキーマと実装の間の継ぎ目をもっと減らし、よりシームレスな実装ができるようになると考えられます。 大AI時代だからこそ、こんな想像力が刺激されるようなAPIが生えると色々手早く試せてワクワクしますね!
人間がレビューしやすい型安全な状態フローの生成 ! 釣りタイトルです!Content Mapperを使って全く役に立たないものを作ってしまっています! 役立つContent Mapperの使い方は TSKaigi Sendai 2026のuhyoさんの「Content Mapper APIの応用例〜CSS Modules、言語ファイル〜 が分かりやすいです。 Mermaid の状態遷移図を、TypeScript の型として扱えるようにしてみました。Mermaidの図から矢印を1本消すと、その矢印を使っていたコードは型エラーになります。 使っているのは、TypeScript 7.1 Beta で導入された Content Mapper です。.ts ではないファイルを仮想的な TypeScript に変換する仕組みで、エラーやエディタの機能が元のファイルの上で動きます。 ! 執筆時点の TypeScript は 7.1.0-beta です。API や設定は正式版までに変わる可能性があります。 Mermaid図を有向グラフと見立てて型に変換する コーヒーマシンの状態遷移図を coffee.mmd として用意します。 ソースには、図に出てこない次の1行も書いています。%% は Mermaid のコメントなので、描画には影響しません。 %% field drink: "コーヒー" | "カフェラテ" | "抹茶ラテ" この状態遷移図を、状態を頂点、イベントを辺とする有向グラフと見立てています。そこで、これを次の型に変換します。 export interface Graph { Menu: { chooseDrink: "PlacingCup"; }; PlacingCup: { backToMenu: "Menu"; pressStart: "Brewing"; }; Brewing: { brewed: "Served"; }; Served: { }; } export interface Fields { drink: "コーヒー" | "カフェラテ" | "抹茶ラテ"; } export interface Payloads { chooseDrink: { drink: Fields["drink"] }; } Content Mapperを使うことでimport type { Graph } from "./coffee.mmd" とMermaidをimportするように書けば、この型を使うことができます。 そうすることで3つのことができるようになります。 図にない遷移を書けなくする 今いる状態から出ている矢印だけをメソッドにすると、呼び出しの順番が図の通りに制限されます。 menu.chooseDrink({ drink: "コーヒー" }).pressStart().brewed(); // OK menu.pressStart(); // エラー: Menu から pressStart の矢印はない 図を変えれば、コードの正しさも変わります。PlacingCup --> Menu: backToMenu を消すと、backToMenu を呼んでいたコードがエラーになります。 Property 'backToMenu' does not exist on type 'Flow<"PlacingCup">'. たとえばReactを使う場合、今の状態から送れるイベントだけをイベントハンドラーとして補完に出るようにもできます。 引数も図の型で制御する 矢印に書いた chooseDrink(drink) の drink は、イベントの引数です。型は %% field drink に書いたものがそのまま使われます。 menu.chooseDrink({ drink: "コーヒー" }); // OK menu.chooseDrink({ drink: "抹茶" }); // エラー: 図にない飲み物 menu.chooseDrink(); // エラー: 引数が必要 menu.chooseDrink({ drink: "コーヒー" }).pressStart({ drink: "コーヒー" }); // エラー: pressStart に引数はない Type '"抹茶"' is not assignable to type '"カフェラテ" | "コーヒー" | "抹茶ラテ"'. メニューを変えるときも、直すのは図だけです。図に "抹茶" を足せば上のコードは通るようになり、"抹茶ラテ" を消せば、それを渡していたコードがエラーになります。 Mermaid図の中に赤線を出す mapper は、図の間違いを位置付きで報告できます。エディタでは .mmd の上に赤線が出ます。 この GIF では、状態が持つデータの名前を drnk と間違えています。Mermaid にとってはただの説明文なので、プレビューは正常に描画されたままです。図としては正しくても、型の世界では間違いとして扱えます。 リネームも図まで届きます。コードの backToMenu をリネームすると、.mmd の中の名前まで書き換わります。 実装 型の定義と mapper の全体です。mapper は58行で、図を読んで型の文字列を組み立てているだけです。興味があれば覗いてみてください。 図にない遷移を書けなくする型 src/flow.ts import type { Graph, Payloads } from "./coffee.mmd"; // 今いる状態から出ている矢印だけが、メソッドになる // 図に引数が書いてあるイベントは、その引数を必須にする export type Flow<S extends keyof Graph> = { [Event in keyof Graph[S]]: ( ...args: Event extends keyof Payloads ? [Payloads[Event]] : [] ) => Flow<Graph[S][Event] & keyof Graph>; }; declare const menu: Flow<"Menu">; menu.chooseDrink({ drink: "コーヒー" }).pressStart().brewed(); menu.chooseDrink({ drink: "コーヒー" }).backToMenu(); mapper 本体と設定 ts-content-mapper という SDK を使っています。appendVerbatim は .mmd の範囲をそのまま写す命令で、元の位置との対応が残るので、リネームが図まで届きます。 mmd-mapper/server.mjs import { MappedCodeBuilder, runContentMapper } from "ts-content-mapper"; runContentMapper({ diagnosticSource: "mermaid", transform({ content }, { positionEncoding }) { // 矢印とフィールドを読み取る const edges = [...content.matchAll(/^\s*(\w+) --> (\w+): (\w+)(?:((\w+)))?$/dgm)]; const fields = [...content.matchAll(/^\s*%% field (\w+): (.+)$/dgm)]; const hasField = (name) => fields.some(([, field]) => field === name); const states = new Set(edges.flatMap(([, from, to]) => [from, to])); const b = new MappedCodeBuilder(content, { positionEncoding }); // 状態ごとの矢印 b.append("export interface Graph {\n"); for (const state of states) { b.append( ${state}: {\n); for (const edge of edges.filter(([, from]) => from === state)) { const [start, end] = edge.indices[3]; // イベント名の位置 b.append(" "); b.appendVerbatim(start, end); b.append(: "${edge[2]}";\n); } b.append(" };\n"); } b.append("}\n"); b.append("export declare const graph: Graph;\n"); // フィールドの型は図の文字列をそのまま写す b.append("export interface Fields {\n"); for (const field of fields) { const [start, end] = field.indices[2]; b.append( ${field[1]}: ); b.appendVerbatim(start, end); b.append(";\n"); } b.append("}\n"); // イベントの引数 b.append("export interface Payloads {\n"); for (const [, , , event, field] of edges) { if (hasField(field)) b.append( ${event}: { ${field}: Fields["${field}"] };\n); } b.append("}\n"); // 図の間違いは、位置を付けて報告できる const diagnostics = edges .filter((edge) => edge[4] && !hasField(edge[4])) .map((edge) => ({ code: 1, messageText: '${edge[4]}' has no '%% field ${edge[4]}: ...' line., start: edge.indices[4][0], length: edge[4].length, })); return { ...b.build(".ts"), diagnostics }; }, }); mmd-mapper/package.json { "name": "mmd-mapper", "type": "module", "dependencies": { "ts-content-mapper": "0.1.1" }, "typescript": { "contentMapper": { "exec": ["node", "server.mjs"], "compilerOptions": [], "dynamicConfig": false } } } tsconfig.json { "compilerOptions": { "module": "esnext", "moduleResolution": "bundler", "strict": true, "noEmit": true, "types": [] }, "contentMappers": [{ "package": "mmd-mapper", "extensions": [".mmd"] }], "include": ["src"] } mapper は外部のプログラムとして動くので、tsc では実行の許可が必要です。 tsc --runExternalCode 実行時に動かす Vite プラグイン Content Mapper の変換結果は tsc とエディタのためのもので、実行時には使われません。そのため、実行時の .mmd は Vite プラグインで JavaScript に変換します。 tsc / エディタ → Content Mapper → 型 Vite(実行時) → Vite プラグイン → JavaScript プラグインは、図から矢印の表を作って graph として export します。mapper が export declare const graph: Graph と宣言しているので、この値には Graph の型が付きます。 mmd-mapper/vite.mjs // .mmd を import したら、矢印の表を JavaScript として返す export function mmd() { return { name: "mmd", enforce: "pre", transform(code, id) { if (!id.split("?")[0].endsWith(".mmd")) return; const graph = {}; for (const [, from, to, event] of code.matchAll(/^\s*(\w+) --> (\w+): (\w+)(?:(\w+))?$/gm)) { graph[from] ??= {}; graph[to] ??= {}; graph[from][event] = to; } return { code: export const graph = ${JSON.stringify(graph)};, map: null }; }, }; } vite.config.mjs import { mmd } from "./mmd-mapper/vite.mjs"; export default { plugins: [mmd()] }; graph を使えば、Flow 型の通りに動く実体を作れます。今の状態から出ている矢印を、次の状態へ進むメソッドにしています。 src/machine.ts import { graph, type Graph } from "./coffee.mmd"; import type { Flow } from "./flow"; export const flowAt = <S extends keyof Graph>(state: S): Flow<S> => Object.fromEntries( Object.entries(graph[state]).map(([event, to]) => [event, () => flowAt(to as keyof Graph)]), ) as Flow<S>; src/main.ts import { flowAt } from "./machine"; const cup = flowAt("Menu").chooseDrink({ drink: "コーヒー" }); console.log(Object.keys(cup)); // ["backToMenu", "pressStart"] 動くデモは super-himetan/mermaid-machine に置いています。 まとめ 図を有向グラフとして型にすると、遷移の順番だけでなく、イベントの引数まで図に書いた型で検査できました。 正直実務で生かそうとするとデータが増えてグラフは複雑になりそうなので、私はコードで読めれば十分だなと思いました。 気が向いたらちゃんと使いものになるContent Mapperの応用も書こうと思います。
TSのcontent mapperを使ってGeoParquetの型推論を便利に賢くしてみる 先日、TypeScript 7.1のベータ版が公開され、content mapperという機能が入りました。 devblogs.microsoft.com ざっくり言うと、TypeScriptが知らない拡張子のファイルをimportしたときに、外部のプログラムに「これをTypeScriptのコードにしてくれ」と頼める機能です。.vue や .svelte のようなファイルを想定したものだと思うのですが、GeoParquetをimportしたら型が付くようにもできるのでは?と思ったのが始まりでした。 以前DuckDB WASMの記事を書いたときも、戻り値がanyで「Typed DuckDBが欲しい...」とぼやいていた記憶があります。それに若干関連するような内容です。 zenn.dev (TypeScript 7.1はまだベータ版なので、仕様は今後変わるかもしれません。) サンプルデータ まずは今回使うデータを見てみます。札幌市の施設データ(架空です)で、10区×100件、計1,000件あります。区ごとにrow group(行のまとまり)を分けて書き出しています。 列はこんな感じです。 列 型 中身 ward 文字列 区名 category 文字列 駅、公園、学校、病院、図書館、コンビニ name 文字列 施設名 opened 整数 開業年(不明ならnull) visitors 整数 年間利用者数 geometry Point 位置(EPSG:6680) ほかにもid、営業中かどうか、更新日時、タグなどが入っています。ogr2ogrで書き出したら、全部の列がnull許容になっていました。 Parquetはrow group×列ごとに、最小値・最大値・nullの数といった統計情報をファイルに書いています。今回のファイルだとこうなっていました。 row group 行数 wardの最小 / 最大 openedのnullの数 0 100 中央区 / 中央区 0 1 100 北区 / 北区 0 2 100 東区 / 東区 0 3 100 白石区 / 白石区 0 4 100 豊平区 / 豊平区 0 5 100 南区 / 南区 25 6 100 西区 / 西区 0 7 100 厚別区 / 厚別区 0 8 100 手稲区 / 手稲区 37 9 100 清田区 / 清田区 0 区ごとに分けているので、wardの最小と最大が同じ値になっています。また、openedのnullは南区と手稲区にしかありません。 これらの情報は、全部ファイル末尾のフッタと呼ばれる部分にまとまっています。 PAR1 | row group 0 | row group 1 | ... | row group 9 | フッタ | フッタの長さ | PAR1 今回のファイルだと、106KBのうちフッタは24KBほどです。本来はクエリを速くするための情報(条件に合わないrow groupを読み飛ばす)なのですが、これを型にも使ってしまおうというのが今回の話です。 やったこと このファイルを読み込むコードを書いてみます。 example/src/sapporo-facilities.ts import type { Row, RowCounts, RowGroups } from "../data/facilities.parquet"; const read = readParquet<Row, RowGroups, RowCounts>(); // 全部読む const all = await read(file); all[0].ward; // "中央区" | "北区" | "東区" | ... | "清田区" all.length; // 1000 // 南区だけ const minami = await read(file, { filter: { ward: "南区" } }); minami[0].ward; // "南区" // 開業年が不明な施設 const unknown = await read(file, { filter: { opened: null } }); unknown[0].ward; // "南区" | "手稲区" // 中央区の名前と開業年だけ const chuo = await read(file, { filter: { ward: "中央区" }, columns: ["name", "opened"] }); chuo[0].opened; // number 型は1行も手で書いていません。.parquet をimportしただけです。 注目してほしいのは3つ目です。さっきの統計の表で、openedにnullがあるのは南区と手稲区だけでした。それが、データを読む前に型でわかっています。 リポジトリはこちらです。 groovyjovy/content-mapper-fgb-gpq スキーマから型をつくる まずはスキーマだけを使ってみます。facilities.parquet をimportすると、TypeScriptからはこう見えます。 export interface Row { id: bigint | null; ward: string | null; opened: number | null; updated_at: Date | null; tags: (string | null)[] | undefined; geometry: Point<"EPSG:6680"> | null; // ... } 列名を打ち間違えたり、数値の列を文字列と比べたりするとちゃんと怒られます。ただ、スキーマ上は全部null許容なので、どの列を触るにもnullチェックが必要です。これだと少し不便ですね。 (INT64がbigintになっているのは、読み込みに使うhyparquetがそう返すからです。型は「hyparquetで読んだときの値の形」に合わせています。) 統計から型を絞り込む ここからが本題です。統計から読み取っているのは以下の3つだけです。 最小と最大が同じなら、そのrow groupは全行が同じ値なので、リテラル型にできる nullの数が0なら、そのrow groupにnullはないので、| null を外せる 行数はそのまま length の型にできる row group 0(中央区)なら { ward: "中央区"; opened: number; ... }、row group 5(南区)なら { ward: "南区"; opened: number | null; ... } という具合に、row groupごとに型をつくります。何も絞らずに読んだときの型は、この10個のunionです。 なので、wardで分岐するとopenedの型も変わります。 for (const f of all) { if (f.ward === "中央区") f.opened; // number if (f.ward === "南区") f.opened; // number | null } 条件で絞ったときは、その条件を満たしえないrow groupを型から消しています。 filter: { ward: "南区" } だと、wardが南区になりうるのはrow group 5だけなので、残りは消える filter: { opened: null } だと、nullの数が0のrow groupはありえないので消えて、南区と手稲区だけが残る filter: { ward: "新宿区" } だと、どれも残らずに never[] になる。読む前から0件とわかります row groupを直接指定することもできます。 const rg = await read(file, { rowGroups: [5, 8] }); rg[0].ward; // "南区" | "手稲区" rg.length; // 200 await read(file, { rowGroups: [10] }); // row groupは0〜9しかないのでエラー 200は100+100を型の上で足しています。TypeScriptの型には足し算がないので、数値を文字列にして1桁ずつ筆算しています。ちょっとやりすぎた気もします。 ちなみに、実行時もhyparquetが同じ統計を見てrow groupを読み飛ばしています。型と実行時で同じ情報を見ているので、話がズレないのがいいところです。 GeoParquetならでは GeoParquetは、Parquetのフッタに geo というJSONを足したものです。 { "primary_column": "geometry", "columns": { "geometry": { "encoding": "WKB", "geometry_types": ["Point"], "crs": { "id": { "authority": "EPSG", "code": 6680 } } } } } ここからジオメトリの種別とCRSを拾って、geometry列を Point<"EPSG:6680"> のような型にしています。CRSが型に載るので、平面直角座標の点を経緯度の点として扱おうとすると怒られます。 const lonLat: Point<"OGC:CRS84"> | null = facility.geometry; // Type '"EPSG:6680"' is not assignable to type '"OGC:CRS84"'. ほかにも、GeoParquetのcovering(各行のbboxを持つ列)を使って、bbox オプションで範囲内のrow groupだけ読めるようにしています。 仕組み content mapper 使う側は、tsconfigにどの拡張子をどのmapperに任せるかを書きます。 example/tsconfig.json { "contentMappers": [ { "package": "geoparquet-content-mapper", "extensions": [".parquet", ".parquet.ref"] } ] } mapper側は、package.jsonに起動方法を書いておきます。 packages/geoparquet-content-mapper/package.json { "typescript": { "contentMapper": { "exec": ["node", "./src/mapper.ts"] } } } あとはTypeScript(tsgo)がmapperを子プロセスとして起動し、.parquet のimportを見つけるたびにJSON-RPCで変換を頼んできます。mapperはTypeScriptのコードを文字列で返すだけです。通信まわりは以下のSDKがやってくれるので、transform を1つ書けば済みます。 sxzz/ts-content-mapper 型チェックのときにmapper(つまり任意のコード)が実行されるので、tsc --runExternalCode のようにフラグが必要です。信頼できるリポジトリでだけ有効にしましょう。 フッタだけ読む Parquetのファイル末尾8バイトは、フッタの長さと PAR1 という文字列です。 $ tail -c 8 facilities.parquet | xxd 00000000: ab5f 0000 5041 5231 ._..PAR1 ab5f0000 はリトルエンディアンで24,491です。なので、末尾8バイトを読んでフッタの長さを知り、そのぶんだけ読めばフッタが手に入ります。解析はhyparquetに任せています。 packages/geoparquet-content-mapper/src/mapper.ts(抜粋) const tail = await readTail(8); const metadataLength = new DataView(tail.buffer, tail.byteOffset).getUint32(0, true); const footer = await readTail(metadataLength + 8); ちなみに、mapperにはファイルの中身も文字列として渡されてくるのですが、UTF-8として読まれているのでバイナリは壊れています。なので、ファイル名から自分で開き直しています。 mapperが返すコード mapperが返しているのはこんなコードです。 export interface Row { ward: string | null; opened: number | null; /* ... */ } type RowGroup0 = { ward: { nulls: "none"; value: "中央区" }; opened: { nulls: "none" }; /* ... */ }; type RowGroup5 = { ward: { nulls: "none"; value: "南区" }; /* ... */ }; export type RowGroups = [RowGroup0, RowGroup1, /* ... */ RowGroup9]; export type RowCounts = [100, 100, 100, 100, 100, 100, 100, 100, 100, 100]; mapperは統計を書き出しているだけで、絞り込みは読み込み関数(hyparquetの薄いラッパー)の型の方でやっています。中身は条件型とmapped typeをひたすら組み合わせているだけなので、興味があればリポジトリを覗いてみてください。 大きいファイルでも フッタだけ読めばいいので、ファイルがどれだけ大きくても型を作るのに必要なのはフッタだけです。 リモートのファイルの場合は、URLを書いた .parquet.ref というファイルを置いておきます。 example/data/places.parquet.ref { "url": "overturemaps-us-west-2.s3.amazonaws.com" } これをimportすると、mapperがHTTPのRangeリクエストでフッタだけ取ってきます。Overture Mapsのplaces(日本を含むファイル、約733MB)だと、取得したのは1.6MBほどでした。256個のrow groupの行数も型の上で足して、length は 5528489 になっています。 実行時も同じで、札幌市中心部のbboxで confidence >= 0.8 のPOIを読むと7,947件、取得したのはフッタとデータを合わせて5MBもありませんでした。 気をつけること 便利そうに見えますが、いくつか気をつけることがあります。 型は「型チェックしたときのファイル」のスナップショットです。データが更新されると型が嘘になります。特に統計で絞り込んだ型や行数は、そのファイルにしか成り立ちません。リモートのURLはバージョンやコミットで固定しておくのが良さそうです ユーザーがアップロードしたファイルのように、実行時に決まるファイルには使えません 統計が書かれていないファイルだと絞り込めません(スキーマの型だけになります) エディタはmapperのプロセスを起動したまま使い続けるので、mapperを修正したら言語サーバーの再起動が必要です。これで結構ハマりました おわりに Parquetに関してはそれっぽいものができました。フッタに書いてある情報だけでここまでできるのは面白いですね。 同じ考え方はGeoZarrなど、メタデータを持つほかの形式にも使えそうなので、そのうち試してみたいです。 参考になれば嬉しいです。
Didn't even wait a day 😆 tsdown & rolldown-plugin-dts now support the TypeScript 7.1 API + content mapper. Native-speed dts for .vue files is finally real ⚡ Try the Vue demo: sxzz/tsgo-vue
Didn't even wait a day 😆 tsdown & rolldown-plugin-dts now support the TypeScript 7.1 API + content mapper. Native-speed dts for .vue files is finally real ⚡ Try the Vue demo: sxzz/tsgo-vue