Serde で構造体と JSON を変換する

serde の属性で Rust の構造体と JS に届く JSON の形を合わせる。rename_all、Option と null / undefined の対応、enum の 4 通りの表し方、chrono での日付の受け渡しを示す。

Rust バックエンド 対象: Tauri 2.x 更新日: 読了目安: 約9分 rust-014
目次
  1. 前提条件
  2. 1. 名前を JS の書き方に合わせる (Rust)
  3. 2. Option と null / undefined を対応させる (Rust)
  4. 3. enum の表し方を選ぶ (Rust)
  5. 4. 日付を受け渡す (Rust)
  6. 5. JS 側の型を合わせる (TypeScript)
  7. 動作確認
  8. よくあるエラーと対処法
  9. 注意点
  10. 関連レシピ

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_ifNone はキーなし(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 of todo, 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 を参照してください。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する