Tauri のコマンドの引数と戻り値、イベントのペイロードは、serde で JSON に変換されて Rust と JS の間を行き来します。構造体に #[derive(Serialize, Deserialize)] を付ければひとまず動きますが、名前の付け方、null と undefined、enum、日付の扱いは Rust と JS で考え方が違うので、属性で JSON の形を合わせます。コマンドでの受け渡しそのものは Rust コマンドに引数を渡す と Rust コマンドからの戻り値を受け取る を参照してください。
前提条件
serde(derive 機能付き)と serde_json はテンプレートの src-tauri/Cargo.toml に入っています。日付を扱う 4 章だけ、chrono を serde 機能付きで追加します。
cd src-tauri
cargo add chrono --features serde
1. 名前を JS の書き方に合わせる (Rust)
#[serde(rename_all = "camelCase")] を付けると、task_id は JSON では taskId になります。この変換は受け取るときにも効くので、JS が task_id で送ると「missing field taskId」になります。Tauri が自動で camelCase にするのはコマンドの引数名だけです。type のように Rust で使えない名前は rename で付けます。
use chrono::{DateTime, NaiveDate, SubsecRound, Utc};
use serde::{Deserialize, Deserializer, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Task {
task_id: u32, // "taskId"
title: String,
#[serde(default)]
done: bool, // 省略されたら false
note: Option<String>, // None は null
#[serde(skip_serializing_if = "Option::is_none")]
due: Option<NaiveDate>, // None ならキーごと省く。"2026-09-30"
created_at: DateTime<Utc>, // "2026-09-12T01:23:45.678Z"
#[serde(with = "chrono::serde::ts_milliseconds")]
updated_at: DateTime<Utc>, // 1789176225678(ミリ秒の数値)
#[serde(rename = "type")]
kind: TaskKind,
}
/// データを持たない enum は文字列になる("todo" / "bug" / "idea")
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum TaskKind {
Todo,
Bug,
Idea,
}
既定では、構造体に無いキーは黙って無視されます。JS 側の綴り違いが隠れるので、受け取り専用の型には deny_unknown_fields を付けてエラーにします。
2. Option と null / undefined を対応させる (Rust)
| Rust の書き方 | Rust から JS へ | JS から Rust へ |
|---|---|---|
Option<T> | None は null | キーなし・null は None |
Option<T> + skip_serializing_if | None はキーなし(undefined) | 同上 |
T + #[serde(default)] | 常に値 | キーなしは既定値。null はエラー |
T(属性なし) | 常に値 | キーなしは「missing field」エラー |
「省略なら変更しない、null なら消す」という部分更新では両者を区別します。Option<Option<T>> と次の関数で、キーなしは None、null は Some(None)、値は Some(Some(v)) になります。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct TaskPatch {
title: Option<String>,
#[serde(default, deserialize_with = "present")]
note: Option<Option<String>>,
}
/// キーがあれば(null でも)Some で包む。キーが無ければ default で None
fn present<'de, D, T>(d: D) -> Result<Option<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
T::deserialize(d).map(Some)
}
3. enum の表し方を選ぶ (Rust)
| 属性 | Circle { radius: 1.5 } | Text("hi") | Empty |
|---|---|---|---|
| なし(既定) | {"Circle":{"radius":1.5}} | {"Text":"hi"} | "Empty" |
tag = "type" | {"type":"Circle","radius":1.5} | 送れない | {"type":"Empty"} |
tag = "type", content = "data" | {"type":"Circle","data":{"radius":1.5}} | {"type":"Text","data":"hi"} | {"type":"Empty"} |
untagged | {"radius":1.5} | "hi" | null |
既定の形はオブジェクトと文字列が混ざり、JS では扱いにくくなります。おすすめは tag = "type" で、TypeScript の判別可能なユニオン型(5 章)とそのまま対応します。Text(String) のようなバリアントがあるなら content も指定します。untagged は失敗時に「data did not match any variant of untagged enum Shape」としか出ず、受け取りには向きません。rename_all はバリアント名にだけ効き、中のフィールドは rename_all_fields で変えます。
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
enum Shape {
Circle { radius: f64 },
Rect { width: f64, height: f64, corner_radius: f64 }, // "cornerRadius"
Empty,
}
4. 日付を受け渡す (Rust)
JS の Date は JSON にすると "2026-09-12T01:23:45.678Z" のような ISO 8601 の文字列になり、chrono の DateTime<Utc> はこの形と相互に変換できます(+09:00 付きも UTC に直して受け取れます)。日付だけなら NaiveDate(<input type="date"> の値と同じ "2026-09-30")、数値がよければ ts_milliseconds でミリ秒にし、JS で new Date(ms) に戻します。
std::time::SystemTime は {"secs_since_epoch":…,"nanos_since_epoch":…} というオブジェクトになるので避けます。JS の Date はミリ秒までなので、Utc::now() は丸めてから渡します。
#[tauri::command]
fn sample_task() -> Task {
let now = Utc::now().trunc_subsecs(3); // JS に合わせてミリ秒に丸める
Task {
task_id: 1,
title: "設計メモ".into(),
done: false,
note: None,
due: None,
created_at: now,
updated_at: now,
kind: TaskKind::Bug,
}
}
#[tauri::command]
fn update_task(task_id: u32, patch: TaskPatch) -> String {
format!("{task_id}: {patch:?}")
}
#[tauri::command]
fn area(shape: Shape) -> f64 {
match shape {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rect { width, height, .. } => width * height,
Shape::Empty => 0.0,
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![sample_task, update_task, area])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
5. JS 側の型を合わせる (TypeScript)
invoke<T> の T は宣言にすぎず、実行時の変換はありません。日付は文字列で届くので型も string にし、使う側で new Date() に通します。tag = "type" の enum は判別可能なユニオン型にすると、switch で型を絞り込めます。
import { invoke } from '@tauri-apps/api/core';
export interface Task {
taskId: number;
title: string;
done: boolean;
note: string | null; // Option<String>
due?: string; // skip_serializing_if 付き。無ければキー自体が無い
createdAt: string; // DateTime<Utc> は文字列で届く
updatedAt: number; // ts_milliseconds
type: 'todo' | 'bug' | 'idea';
}
// 省略: 変更しない / null: 消す / 文字列: 書き換える
export type TaskPatch = { title?: string; note?: string | null };
export type Shape =
| { type: 'circle'; radius: number }
| { type: 'rect'; width: number; height: number; cornerRadius: number }
| { type: 'empty' };
export function label(s: Shape): string {
switch (s.type) {
case 'circle':
return `円 r=${s.radius}`;
case 'rect':
return `長方形 ${s.width}x${s.height}`;
case 'empty':
return '空';
}
}
const task = await invoke<Task>('sample_task');
console.log(new Date(task.createdAt).toLocaleString());
動作確認
npm run tauri dev で起動し、次の関数をボタンなどから呼びます。
import { invoke } from '@tauri-apps/api/core';
export async function checkSerde() {
console.log(JSON.stringify(await invoke('sample_task')));
for (const patch of [{}, { note: null }, { note: 'メモ' }, { nickName: 'x' }]) {
try {
console.log(await invoke<string>('update_task', { taskId: 1, patch }));
} catch (e) {
console.error(e);
}
}
console.log(await invoke<number>('area', { shape: { type: 'rect', width: 2, height: 1, cornerRadius: 0 } }));
}
コンソールに次のように出ます(時刻は実行時のもの)。省略・null・値が区別され、綴りの違うキーはエラーになります。
{"taskId":1,"title":"設計メモ","done":false,"note":null,"createdAt":"2026-09-12T01:23:45.678Z","updatedAt":1789176225678,"type":"bug"}
1: TaskPatch { title: None, note: None }
1: TaskPatch { title: None, note: Some(None) }
1: TaskPatch { title: None, note: Some(Some("メモ")) }
invalid args `patch` for command `update_task`: unknown field `nickName`, expected `title` or `note`
2
よくあるエラーと対処法
- 「missing field
taskId」: キーが無いか、名前の書き方が違います。省略を許すならOptionか#[serde(default)]にします。 - 「invalid type: null, expected a boolean」:
#[serde(default)]のフィールドにnullが来ています。defaultが効くのはキーが無いときだけなので、nullもありうるならOption<T>にします。 - 「unknown variant
Todo, expected one oftodo,bug,idea」: enum の文字列の大小文字がrename_allと合っていません。 - 「premature end of input」:
DateTime<Utc>に日付だけやタイムゾーンの無い文字列(<input type="datetime-local">の値など)を渡しています。JS でnew Date(value).toISOString()にしてから送ります。 - 「cannot serialize tagged newtype variant ... containing a string」を含むエラー:
tagだけの enum でText(String)を返しています。contentを足します。
注意点
f64のNaNや無限大は JSON に無いのでnullで届きます。HashMap<u32, T>のキーは文字列("1")になります。- 設定の JSON ファイルは
serde_json::to_string_pretty()とfrom_str()で読み書きします。構造体全体に#[serde(default)]を付けておくと、古いファイルに無い項目も既定値で読めます(保存先は fs-018)。 - 2^53 を超える整数は front-003、
Errに入れるエラー型は front-004 を参照してください。
